From 60c31e8894b50b6e6b60850eb708b871e7d02527 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sat, 17 Oct 2020 20:01:24 +0800 Subject: [PATCH] add models / services / handlers of user / token / 2fa, add web server command --- cmd/database.go | 3 +- cmd/webserver.go | 176 ++++++++++++++ go.mod | 3 + go.sum | 15 ++ lab.go | 31 ++- pkg/api/authorizations.go | 182 +++++++++++++++ pkg/api/default.go | 20 ++ pkg/api/tokens.go | 120 ++++++++++ pkg/api/twofactor_authorizations.go | 278 ++++++++++++++++++++++ pkg/api/users.go | 179 ++++++++++++++ pkg/errs/error.go | 5 + pkg/middlewares/authorization.go | 79 +++++++ pkg/models/token_record.go | 28 +++ pkg/models/two_factor.go | 31 +++ pkg/models/two_factor_recovery_code.go | 13 ++ pkg/services/tokens.go | 282 +++++++++++++++++++++++ pkg/services/twofactor_authorizations.go | 204 ++++++++++++++++ public/robots.txt | 2 + 18 files changed, 1649 insertions(+), 2 deletions(-) create mode 100644 cmd/webserver.go create mode 100644 pkg/api/authorizations.go create mode 100644 pkg/api/default.go create mode 100644 pkg/api/tokens.go create mode 100644 pkg/api/twofactor_authorizations.go create mode 100644 pkg/api/users.go create mode 100644 pkg/middlewares/authorization.go create mode 100644 pkg/models/token_record.go create mode 100644 pkg/models/two_factor.go create mode 100644 pkg/models/two_factor_recovery_code.go create mode 100644 pkg/services/tokens.go create mode 100644 pkg/services/twofactor_authorizations.go create mode 100644 public/robots.txt diff --git a/cmd/database.go b/cmd/database.go index a6866632..a18128ef 100644 --- a/cmd/database.go +++ b/cmd/database.go @@ -29,7 +29,8 @@ func updateDatabaseStructure(c *cli.Context) error { log.BootInfof("[database.updateDatabaseStructure] starting maintaining") - _ = datastore.Container.UserStore.SyncStructs(new(models.User)) + _ = datastore.Container.UserStore.SyncStructs(new(models.User), new(models.TwoFactor), new(models.TwoFactorRecoveryCode)) + _ = datastore.Container.TokenStore.SyncStructs(new(models.TokenRecord)) log.BootInfof("[database.updateDatabaseStructure] maintained successfully") diff --git a/cmd/webserver.go b/cmd/webserver.go new file mode 100644 index 00000000..8255c918 --- /dev/null +++ b/cmd/webserver.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" + "github.com/urfave/cli" + + "github.com/mayswind/lab/pkg/api" + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/middlewares" + "github.com/mayswind/lab/pkg/requestid" + "github.com/mayswind/lab/pkg/settings" + "github.com/mayswind/lab/pkg/utils" + "github.com/mayswind/lab/pkg/validators" +) + +var WebServer = cli.Command{ + Name: "server", + Usage: "lab web server operation", + Subcommands: []cli.Command{ + { + Name: "run", + Usage: "Run lab web server", + Action: startWebServer, + }, + }, +} + +func startWebServer(c *cli.Context) error { + config, err := initializeSystem(c) + + if err != nil { + return err + } + + log.BootInfof("[server.startWebServer] static root path is %s", config.StaticRootPath) + + err = requestid.InitializeRequestIdGenerator(config) + + if err != nil { + log.BootErrorf("[server.startWebServer] initializes requestid generator failed, because %s", err.Error()) + return err + } + + serverInfo := fmt.Sprintf("current server id is %d, current instance id is %d", requestid.Container.Current.GetCurrentServerUniqId(), requestid.Container.Current.GetCurrentInstanceUniqId()) + uuidServerInfo := "" + if config.UuidGeneratorType == settings.UUID_GENERATOR_TYPE_INTERNAL { + uuidServerInfo = fmt.Sprintf(", current uuid server id is %d", config.UuidServerId) + } + + log.BootInfof("[server.startWebServer] %s%s", serverInfo, uuidServerInfo) + + if config.Mode == settings.MODE_PRODUCTION { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + router.Use(bindMiddleware(middlewares.Recovery)) + + if config.EnableGZip { + router.Use(gzip.Gzip(gzip.DefaultCompression)) + } + + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + _ = v.RegisterValidation("notBlank", validators.NotBlank) + _ = v.RegisterValidation("validUsername", validators.ValidUsername) + _ = v.RegisterValidation("validEmail", validators.ValidEmail) + } + + router.NoRoute(bindApi(api.Default.ApiNotFound)) + router.NoMethod(bindApi(api.Default.MethodNotAllowed)) + + router.StaticFile("/", filepath.Join(config.StaticRootPath, "index.html")) + router.StaticFile("login", filepath.Join(config.StaticRootPath, "login.html")) + + if config.EnableUserRegister { + router.StaticFile("register", filepath.Join(config.StaticRootPath, "register.html")) + } + + router.StaticFile("robots.txt", filepath.Join(config.StaticRootPath, "robots.txt")) + router.Static("/js", filepath.Join(config.StaticRootPath, "js")) + router.Static("/css", filepath.Join(config.StaticRootPath, "css")) + router.Static("/img", filepath.Join(config.StaticRootPath, "img")) + router.Static("/lang", filepath.Join(config.StaticRootPath, "lang")) + + apiRoute := router.Group("/api") + + apiRoute.Use(bindMiddleware(middlewares.RequestId(config))) + apiRoute.Use(bindMiddleware(middlewares.RequestLog)) + { + apiRoute.POST("/authorize.json", bindApi(api.Authorizations.AuthorizeHandler)) + + if config.EnableTwoFactor { + twoFactorRoute := apiRoute.Group("/2fa") + twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization)) + { + twoFactorRoute.POST("/authorize.json", bindApi(api.Authorizations.TwoFactorAuthorizeHandler)) + twoFactorRoute.POST("/recovery.json", bindApi(api.Authorizations.TwoFactorAuthorizeByRecoveryCodeHandler)) + } + } + + if config.EnableUserRegister { + apiRoute.POST("/register.json", bindApi(api.Users.UserRegisterHandler)) + } + + apiV1Route := apiRoute.Group("/v1") + apiV1Route.Use(bindMiddleware(middlewares.JWTAuthorization)) + { + // Tokens + apiV1Route.GET("/tokens/list.json", bindApi(api.Tokens.TokenListHandler)) + apiV1Route.POST("/tokens/revoke.json", bindApi(api.Tokens.TokenRevokeHandler)) + apiV1Route.POST("/tokens/refresh.json", bindApi(api.Tokens.TokenRefreshHandler)) + + // Users + apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler)) + apiV1Route.POST("/users/profile/update.json", bindApi(api.Users.UserUpdateProfileHandler)) + + // Two Factor Authorization + if config.EnableTwoFactor { + apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler)) + apiV1Route.POST("/users/2fa/enable/request.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableRequestHandler)) + apiV1Route.POST("/users/2fa/enable/confirm.json", bindApi(api.TwoFactorAuthorizations.TwoFactorEnableConfirmHandler)) + apiV1Route.POST("/users/2fa/disable.json", bindApi(api.TwoFactorAuthorizations.TwoFactorDisableHandler)) + apiV1Route.POST("/users/2fa/recovery/regenerate.json", bindApi(api.TwoFactorAuthorizations.TwoFactorRecoveryCodeRegenerateHandler)) + } + } + } + + listenAddr := fmt.Sprintf("%s:%d", config.HttpAddr, config.HttpPort) + + if config.Protocol == settings.SCHEME_SOCKET { + log.BootInfof("[server.startWebServer] will run at socks:%s", config.UnixSocketPath) + err = router.RunUnix(config.UnixSocketPath) + } else if config.Protocol == settings.SCHEME_HTTP { + log.BootInfof("[server.startWebServer] will run at http://%s", listenAddr) + err = router.Run(listenAddr) + } else if config.Protocol == settings.SCHEME_HTTPS { + log.BootInfof("[server.startWebServer] will run at https://%s", listenAddr) + err = router.RunTLS(listenAddr, config.CertFile, config.CertKeyFile) + } else { + err = errs.ErrInvalidProtocol + } + + if err != nil { + log.BootErrorf("[server.startWebServer] cannot start, because %s", err) + return err + } + + return nil +} + +func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + fn(core.WrapContext(c)) + } +} + +func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc { + return func(ginCtx *gin.Context) { + c := core.WrapContext(ginCtx) + result, err := fn(c) + + if err != nil { + utils.PrintErrorResult(c, err) + } else { + utils.PrintSuccessResult(c, result) + } + } +} diff --git a/go.mod b/go.mod index a5e42d45..1404c9a5 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,17 @@ go 1.14 require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gin-contrib/gzip v0.0.3 github.com/gin-gonic/gin v1.6.3 github.com/go-playground/validator/v10 v10.2.0 github.com/go-sql-driver/mysql v1.5.0 github.com/lib/pq v1.8.0 github.com/mattn/go-sqlite3 v1.14.4 + github.com/pquerna/otp v1.2.0 github.com/sirupsen/logrus v1.7.0 github.com/smartystreets/goconvey v1.6.4 // indirect github.com/stretchr/testify v1.4.0 + github.com/urfave/cli v1.22.4 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 gopkg.in/ini.v1 v1.62.0 xorm.io/core v0.7.3 diff --git a/go.sum b/go.sum index 7119b159..1a218baa 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,12 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +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/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,6 +15,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gin-contrib/gzip v0.0.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k= +github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= @@ -62,6 +69,12 @@ github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 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.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= +github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= @@ -79,6 +92,8 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/lab.go b/lab.go index 1fde0011..7bab48da 100644 --- a/lab.go +++ b/lab.go @@ -1 +1,30 @@ -package lab +package main + +import ( + "os" + + "github.com/urfave/cli" + + "github.com/mayswind/lab/cmd" +) + + +const LAB_VERSION = "0.1.0" + +func main() { + app := cli.NewApp() + app.Name = "lab" + app.Usage = "A lightweight account book app hosted by yourself." + app.Version = LAB_VERSION + app.Commands = []cli.Command{ + cmd.WebServer, + cmd.Database, + } + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "conf-path", + Usage: "Custom config `FILE` path", + }, + } + app.Run(os.Args) +} diff --git a/pkg/api/authorizations.go b/pkg/api/authorizations.go new file mode 100644 index 00000000..f996c0df --- /dev/null +++ b/pkg/api/authorizations.go @@ -0,0 +1,182 @@ +package api + +import ( + "github.com/pquerna/otp/totp" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" +) + +type AuthorizationsApi struct { + users *services.UserService + tokens *services.TokenService + twoFactorAuthorizations *services.TwoFactorAuthorizationService +} + +var ( + Authorizations = &AuthorizationsApi{ + users: services.Users, + tokens: services.Tokens, + twoFactorAuthorizations: services.TwoFactorAuthorizations, + } +) + +func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *errs.Error) { + var credential models.UserLoginRequest + err := c.ShouldBindJSON(&credential) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] parse request failed, because %s", err.Error()) + return nil, errs.ErrLoginNameOrPasswordInvalid + } + + user, err := a.users.GetUserByUsernameOrEmailAndPassword(credential.LoginName, credential.Password) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because %s", credential.LoginName, err.Error()) + return nil, errs.ErrLoginNameOrPasswordWrong + } + + err = a.users.UpdateUserLastLoginTime(user.Uid) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to update last login time for user \"uid:%d\", because %s", user.Uid, err.Error()) + } + + twoFactorEnable := a.tokens.CurrentConfig().EnableTwoFactor + + if twoFactorEnable { + twoFactorEnable, err = a.twoFactorAuthorizations.ExistsTwoFactorSetting(user.Uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to check two factor setting for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrSystemError) + } + } + + var token string + var claims *core.UserTokenClaims + + if twoFactorEnable { + token, claims, err = a.tokens.CreateRequire2FAToken(user, c) + } else { + token, claims, err = a.tokens.CreateToken(user, c) + } + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.AuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt) + return token, nil +} + +func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interface{}, *errs.Error) { + var credential models.TwoFactorLoginRequest + err := c.ShouldBindJSON(&credential) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] parse request failed, because %s", err.Error()) + return nil, errs.ErrPasscodeInvalid + } + + uid := c.GetCurrentUid() + twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrSystemError) + } + + if !totp.Validate(credential.Passcode, twoFactorSetting.Secret) { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] passcode is invalid for user \"uid:%d\"", uid) + return nil, errs.ErrPasscodeInvalid + } + + user, err := a.users.GetUserById(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error()) + return nil, errs.ErrUserNotFound + } + + oldTokenClaims := c.GetTokenClaims() + err = a.tokens.DeleteTokenByClaims(oldTokenClaims) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error()) + } + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has authorized two factor via passcode, token will be expired at %d", user.Uid, claims.ExpiresAt) + return token, nil +} + +func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Context) (interface{}, *errs.Error) { + var credential models.TwoFactorRecoveryCodeLoginRequest + err := c.ShouldBindJSON(&credential) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] parse request failed, because %s", err.Error()) + return nil, errs.ErrTwoFactorRecoveryCodeInvalid + } + + uid := c.GetCurrentUid() + enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor setting for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrSystemError) + } + + if !enableTwoFactor { + return nil, errs.ErrTwoFactorKeyIsNotEnabled + } + + user, err := a.users.GetUserById(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error()) + return nil, errs.ErrUserNotFound + } + + err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(uid, credential.RecoveryCode, user.Salt) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get two factor recovery code for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrTwoFactorRecoveryCodeNotExist) + } + + oldTokenClaims := c.GetTokenClaims() + err = a.tokens.DeleteTokenByClaims(oldTokenClaims) + + if err != nil { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error()) + } + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.ErrorfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has authorized two factor via recovery code \"%s\", token will be expired at %d", user.Uid, credential.RecoveryCode, claims.ExpiresAt) + return token, nil +} diff --git a/pkg/api/default.go b/pkg/api/default.go new file mode 100644 index 00000000..ae45bb17 --- /dev/null +++ b/pkg/api/default.go @@ -0,0 +1,20 @@ +package api + +import ( + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" +) + +type DefaultApi struct {} + +var ( + Default = &DefaultApi{} +) + +func (a *DefaultApi) ApiNotFound(c *core.Context) (interface{}, *errs.Error) { + return nil, errs.ErrApiNotFound +} + +func (a *DefaultApi) MethodNotAllowed(c *core.Context) (interface{}, *errs.Error) { + return nil, errs.ErrMethodNotAllowed +} diff --git a/pkg/api/tokens.go b/pkg/api/tokens.go new file mode 100644 index 00000000..04a6fba8 --- /dev/null +++ b/pkg/api/tokens.go @@ -0,0 +1,120 @@ +package api + +import ( + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" + "github.com/mayswind/lab/pkg/utils" +) + +type TokensApi struct { + tokens *services.TokenService + users *services.UserService +} + +var ( + Tokens = &TokensApi{ + tokens: services.Tokens, + users: services.Users, + } +) + +func (a *TokensApi) TokenListHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + tokens, err := a.tokens.GetAllTokensByUid(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[tokens.TokenListHandler] failed to get all tokens for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + tokenResps := make([]*models.TokenInfoResponse, len(tokens)) + claims := c.GetTokenClaims() + + for i := 0; i < len(tokens); i++ { + token := tokens[i] + tokenResp := &models.TokenInfoResponse{ + TokenId: a.tokens.GenerateTokenId(token), + TokenType: token.TokenType, + UserAgent: token.UserAgent, + CreatedAt: token.CreatedUnixTime, + ExpiredAt: token.ExpiredUnixTime, + } + + if utils.Int64ToString(token.Uid) == claims.Id && utils.Int64ToString(token.UserTokenId) == claims.UserTokenId && token.CreatedUnixTime == claims.IssuedAt { + tokenResp.IsCurrent = true + } + + tokenResps[i] = tokenResp + } + + return tokenResps, nil +} + +func (a *TokensApi) TokenRevokeHandler(c *core.Context) (interface{}, *errs.Error) { + var tokenRevokeReq models.TokenRevokeRequest + err := c.ShouldBindJSON(&tokenRevokeReq) + + if err != nil { + log.WarnfWithRequestId(c, "[tokens.TokenRevokeHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + tokenRecord, err := a.tokens.ParseFromTokenId(tokenRevokeReq.TokenId) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to parse token \"id:%s\", because %s", tokenRevokeReq.TokenId, err.Error()) + } + + return nil, errs.Or(err, errs.ErrInvalidTokenId) + } + + uid := c.GetCurrentUid() + + if tokenRecord.Uid != uid { + log.WarnfWithRequestId(c, "[token.TokenRevokeHandler] token \"id:%s\" is not owned by user \"uid:%d\"", tokenRevokeReq.TokenId, uid) + return nil, errs.ErrInvalidTokenId + } + + err = a.tokens.DeleteToken(tokenRecord) + + if err != nil { + log.ErrorfWithRequestId(c, "[token.TokenRevokeHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", tokenRevokeReq.TokenId, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.InfofWithRequestId(c, "[token.TokenRevokeHandler] user \"uid:%d\" has revoked token \"id:%s\"", uid, tokenRevokeReq.TokenId) + return true, nil +} + +func (a *TokensApi) TokenRefreshHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(uid) + + if err != nil { + log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error()) + return nil, errs.ErrUserNotFound + } + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.ErrorfWithRequestId(c, "[token.TokenRefreshHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrTokenGenerating) + } + + oldTokenClaims := c.GetTokenClaims() + err = a.tokens.DeleteTokenByClaims(oldTokenClaims) + + if err != nil { + log.WarnfWithRequestId(c, "[token.TokenRefreshHandler] failed to revoke token \"id:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error()) + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[token.TokenRefreshHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt) + return token, nil +} diff --git a/pkg/api/twofactor_authorizations.go b/pkg/api/twofactor_authorizations.go new file mode 100644 index 00000000..1f813e31 --- /dev/null +++ b/pkg/api/twofactor_authorizations.go @@ -0,0 +1,278 @@ +package api + +import ( + "bytes" + "encoding/base64" + "image/png" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" +) + +type TwoFactorAuthorizationsApi struct { + twoFactorAuthorizations *services.TwoFactorAuthorizationService + users *services.UserService + tokens *services.TokenService +} + +var ( + TwoFactorAuthorizations = &TwoFactorAuthorizationsApi{ + twoFactorAuthorizations: services.TwoFactorAuthorizations, + users: services.Users, + tokens: services.Tokens, + } +) + +func (a *TwoFactorAuthorizationsApi) TwoFactorStatusHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + twoFactorSetting, err := a.twoFactorAuthorizations.GetUserTwoFactorSettingByUid(uid) + + if err == errs.ErrTwoFactorKeyIsNotEnabled { + statusResp := &models.TwoFactorStatusResponse{ + Enable: false, + } + + return statusResp, nil + } + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorStatusHandler] failed to get two factor setting, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + statusResp := &models.TwoFactorStatusResponse{ + Enable: true, + CreatedAt: twoFactorSetting.CreatedUnixTime, + } + + return statusResp, nil +} + +func (a *TwoFactorAuthorizationsApi) TwoFactorEnableRequestHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + enabled, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to check two factor setting, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if enabled { + return nil, errs.ErrTwoFactorKeyAlreadyEnabled + } + + user, err := a.users.GetUserById(uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + key, err := a.twoFactorAuthorizations.GenerateTwoFactorSecret(user) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor secret, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + img, err := key.Image(240, 240) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableRequestHandler] failed to generate two factor qrcode, because %s", err.Error()) + return nil, errs.ErrOperationFailed + } + + imgData := &bytes.Buffer{} + + if err = png.Encode(imgData, img); err != nil { + return nil, errs.ErrOperationFailed + } + + enableResp := &models.TwoFactorEnableResponse{ + Secret: key.Secret(), + QRCode: "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgData.Bytes()), + } + + return enableResp, nil +} + +func (a *TwoFactorAuthorizationsApi) TwoFactorEnableConfirmHandler(c *core.Context) (interface{}, *errs.Error) { + var confirmReq models.TwoFactorEnableConfirmRequest + err := c.ShouldBindJSON(&confirmReq) + + if err != nil { + log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + exists, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to check two factor setting, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if exists { + return nil, errs.ErrTwoFactorKeyAlreadyEnabled + } + + user, err := a.users.GetUserById(uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + twoFactorSetting := &models.TwoFactor{ + Uid: uid, + Secret: confirmReq.Secret, + } + + if !totp.Validate(confirmReq.Passcode, confirmReq.Secret) { + log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] passcode is invalid") + return nil, errs.ErrPasscodeInvalid + } + + recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes() + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + err = a.twoFactorAuthorizations.CreateTwoFactorSetting(twoFactorSetting) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create two factor setting for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" has enabled two factor authorization", uid) + + now := time.Now().Unix() + err = a.tokens.DeleteTokensBeforeTime(uid, now) + + if err == nil { + log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid) + } else { + log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error()) + } + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + + confirmResp := &models.TwoFactorEnableConfirmResponse{ + RecoveryCodes: recoveryCodes, + } + + return confirmResp, nil + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorEnableConfirmHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt) + + confirmResp := &models.TwoFactorEnableConfirmResponse{ + Token: token, + RecoveryCodes: recoveryCodes, + } + + return confirmResp, nil +} + +func (a *TwoFactorAuthorizationsApi) TwoFactorDisableHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to check two factor setting, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if !enableTwoFactor { + return nil, errs.ErrTwoFactorKeyIsNotEnabled + } + + err = a.twoFactorAuthorizations.DeleteTwoFactorRecoveryCodes(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor recovery codes for user \"uid:%d\"", uid) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + err = a.twoFactorAuthorizations.DeleteTwoFactorSetting(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] failed to delete two factor setting for user \"uid:%d\"", uid) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorDisableHandler] user \"uid:%d\" has disabled two factor authorization", uid) + + return true, nil +} + +func (a *TwoFactorAuthorizationsApi) TwoFactorRecoveryCodeRegenerateHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + enableTwoFactor, err := a.twoFactorAuthorizations.ExistsTwoFactorSetting(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to check two factor setting, because %s", err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if !enableTwoFactor { + return nil, errs.ErrTwoFactorKeyIsNotEnabled + } + + recoveryCodes, err := a.twoFactorAuthorizations.GenerateTwoFactorRecoveryCodes() + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to generate two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + user, err := a.users.GetUserById(uid) + + if err != nil { + log.WarnfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to get user for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.ErrUserNotFound + } + + err = a.twoFactorAuthorizations.CreateTwoFactorRecoveryCodes(uid, recoveryCodes, user.Salt) + + if err != nil { + log.ErrorfWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] failed to create two factor recovery codes for user \"uid:%d\", because %s", uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + recoveryCodesResp := &models.TwoFactorEnableConfirmResponse{ + RecoveryCodes: recoveryCodes, + } + + log.InfofWithRequestId(c, "[twofactor_authorizations.TwoFactorRecoveryCodeRegenerateHandler] user \"uid:%d\" has regenerated two factor recovery codes", uid) + + return recoveryCodesResp, nil +} diff --git a/pkg/api/users.go b/pkg/api/users.go new file mode 100644 index 00000000..1059aa66 --- /dev/null +++ b/pkg/api/users.go @@ -0,0 +1,179 @@ +package api + +import ( + "strings" + "time" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" + "github.com/mayswind/lab/pkg/utils" +) + +type UsersApi struct { + users *services.UserService + tokens *services.TokenService +} + +var ( + Users = &UsersApi{ + users: services.Users, + tokens: services.Tokens, + } +) + +func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Error) { + var userRegisterReq models.UserRegisterRequest + err := c.ShouldBindJSON(&userRegisterReq) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserRegisterHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + userRegisterReq.Username = strings.TrimSpace(userRegisterReq.Username) + userRegisterReq.Email = strings.TrimSpace(userRegisterReq.Email) + userRegisterReq.Nickname = strings.TrimSpace(userRegisterReq.Nickname) + + user := &models.User{ + Username: userRegisterReq.Username, + Email: userRegisterReq.Email, + Nickname: userRegisterReq.Nickname, + Password: userRegisterReq.Password, + } + + err = a.users.CreateUser(user) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to create user \"%s\", because %s", user.Username, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid) + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserRegisterHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return true, nil + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"uid:%d\" has logined, token will be expired at %d", user.Uid, claims.ExpiresAt) + return token, nil +} + +func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserRegisterHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + userResp := &models.UserProfileResponse{ + Uid : utils.Int64ToString(user.Uid), + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Type: user.Type, + CreatedAt: user.CreatedUnixTime, + UpdatedAt: user.UpdatedUnixTime, + LastLoginAt: user.LastLoginUnixTime, + } + + return userResp, nil +} + +func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs.Error) { + var userUpdateReq models.UserProfileUpdateRequest + err := c.ShouldBindJSON(&userUpdateReq) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + userUpdateReq.Email = strings.TrimSpace(userUpdateReq.Email) + userUpdateReq.Nickname = strings.TrimSpace(userUpdateReq.Nickname) + + anythingUpdate := false + + if userUpdateReq.Email != "" && userUpdateReq.Email != user.Email { + anythingUpdate = true + } else { + userUpdateReq.Email = "" + } + + if userUpdateReq.Password != "" && !a.users.IsPasswordEqualsUserPassword(userUpdateReq.Password, user) { + anythingUpdate = true + } else { + userUpdateReq.Password = "" + } + + if userUpdateReq.Nickname != "" && userUpdateReq.Nickname != user.Nickname { + anythingUpdate = true + } else { + userUpdateReq.Nickname = "" + } + + if !anythingUpdate { + return nil, errs.ErrNothingWillBeUpdated + } + + user.Email = userUpdateReq.Email + user.Password = userUpdateReq.Password + user.Nickname = userUpdateReq.Nickname + + keyProfileUpdated, err := a.users.UpdateUser(user) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" has updated successfully", user.Uid) + + if keyProfileUpdated { + now := time.Now().Unix() + err = a.tokens.DeleteTokensBeforeTime(uid, now) + + if err == nil { + log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid) + } else { + log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error()) + } + + token, claims, err := a.tokens.CreateToken(user, c) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserUpdateProfileHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return true, nil + } + + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[users.UserUpdateProfileHandler] user \"uid:%d\" token refreshed, new token will be expired at %d", user.Uid, claims.ExpiresAt) + return token, nil + } else { + return true, nil + } +} diff --git a/pkg/errs/error.go b/pkg/errs/error.go index 8e6402ba..d16eab84 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -71,3 +71,8 @@ func Or(err error, defaultErr *Error) *Error { return defaultErr } } + +func IsCustomError(err error) bool { + _, ok := err.(*Error); + return ok +} diff --git a/pkg/middlewares/authorization.go b/pkg/middlewares/authorization.go new file mode 100644 index 00000000..924f7320 --- /dev/null +++ b/pkg/middlewares/authorization.go @@ -0,0 +1,79 @@ +package middlewares + +import ( + "time" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/services" + "github.com/mayswind/lab/pkg/utils" +) + +func JWTAuthorization(c *core.Context) { + claims, err := getTokenClaims(c) + + if err != nil { + utils.PrintErrorResult(c, err) + return + } + + if claims.Type == core.USER_TOKEN_TYPE_REQUIRE_2FA { + log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token requires 2fa", claims.Id) + utils.PrintErrorResult(c, errs.ErrTokenRequire2FA) + return + } + + if claims.Type != core.USER_TOKEN_TYPE_NORMAL { + log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token type is invalid", claims.Id) + utils.PrintErrorResult(c, errs.ErrInvalidTokenType) + return + } + + c.SetTokenClaims(claims) + c.Next() +} + +func JWTTwoFactorAuthorization(c *core.Context) { + claims, err := getTokenClaims(c) + + if err != nil { + utils.PrintErrorResult(c, err) + return + } + + if claims.Type != core.USER_TOKEN_TYPE_REQUIRE_2FA { + log.WarnfWithRequestId(c, "[authorization.JWTTwoFactorAuthorization] user \"uid:%s\" token is not need two factor authorization", claims.Id) + utils.PrintErrorResult(c, errs.ErrTokenNotRequire2FA) + return + } + + c.SetTokenClaims(claims) + c.Next() +} + +func getTokenClaims(c *core.Context) (*core.UserTokenClaims, *errs.Error) { + token, claims, err := services.Tokens.ParseToken(c) + + if err != nil { + log.WarnfWithRequestId(c, "[authorization.getTokenClaims] failed to parse token, because %s", err.Error()) + return nil, errs.ErrUnauthorizedAccess + } + + if !token.Valid { + log.WarnfWithRequestId(c, "[authorization.getTokenClaims] token is invalid") + return nil, errs.ErrInvalidToken + } + + if !claims.VerifyExpiresAt(time.Now().Unix(), true) { + log.WarnfWithRequestId(c, "[authorization.getTokenClaims] token is expired") + return nil, errs.ErrTokenExpired + } + + if claims.Id == "" { + log.WarnfWithRequestId(c, "[authorization.getTokenClaims] user id in token is empty") + return nil, errs.ErrInvalidToken + } + + return claims, nil +} diff --git a/pkg/models/token_record.go b/pkg/models/token_record.go new file mode 100644 index 00000000..10c605c1 --- /dev/null +++ b/pkg/models/token_record.go @@ -0,0 +1,28 @@ +package models + +import "github.com/mayswind/lab/pkg/core" + +const TOKEN_USER_AGENT_MAX_LENGTH = 255 + +type TokenRecord struct { + Uid int64 `xorm:"PK"` + UserTokenId int64 `xorm:"PK"` + TokenType core.TokenType `xorm:"TINYINT NOT NULL"` + Secret string `xorm:"VARCHAR(10) NOT NULL"` + UserAgent string `xorm:"VARCHAR(255)"` + CreatedUnixTime int64 `xorm:"PK"` + ExpiredUnixTime int64 +} + +type TokenRevokeRequest struct { + TokenId string `json:"tokenId" binding:"required,notBlank"` +} + +type TokenInfoResponse struct { + TokenId string `json:"tokenId"` + TokenType core.TokenType `json:"tokenType"` + UserAgent string `json:"userAgent"` + CreatedAt int64 `json:"createdAt"` + ExpiredAt int64 `json:"expiredAt"` + IsCurrent bool `json:"isCurrent"` +} diff --git a/pkg/models/two_factor.go b/pkg/models/two_factor.go new file mode 100644 index 00000000..9838f0c6 --- /dev/null +++ b/pkg/models/two_factor.go @@ -0,0 +1,31 @@ +package models + +type TwoFactor struct { + Uid int64 `xorm:"PK"` + Secret string `xorm:"VARCHAR(80) NOT NULL"` + CreatedUnixTime int64 +} + +type TwoFactorLoginRequest struct { + Passcode string `json:"passcode" binding:"required,notBlank,len=6"` +} + +type TwoFactorEnableConfirmRequest struct { + Secret string `json:"secret" binding:"required,notBlank,len=32"` + Passcode string `json:"passcode" binding:"required,notBlank,len=6"` +} + +type TwoFactorEnableResponse struct { + Secret string `json:"secret"` + QRCode string `json:"qrcode"` +} + +type TwoFactorEnableConfirmResponse struct { + Token string `json:"token,omitempty"` + RecoveryCodes []string `json:"recoveryCodes"` +} + +type TwoFactorStatusResponse struct { + Enable bool `json:"enable"` + CreatedAt int64 `json:"createdAt,omitempty"` +} diff --git a/pkg/models/two_factor_recovery_code.go b/pkg/models/two_factor_recovery_code.go new file mode 100644 index 00000000..fea89303 --- /dev/null +++ b/pkg/models/two_factor_recovery_code.go @@ -0,0 +1,13 @@ +package models + +type TwoFactorRecoveryCode struct { + Uid int64 `xorm:"PK"` + RecoveryCode string `xorm:"VARCHAR(64) PK"` + Used bool `xorm:"NOT NULL"` + CreatedUnixTime int64 + UsedUnixTime int64 +} + +type TwoFactorRecoveryCodeLoginRequest struct { + RecoveryCode string `json:"recoveryCode" binding:"required,notBlank,len=11"` +} diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go new file mode 100644 index 00000000..74849426 --- /dev/null +++ b/pkg/services/tokens.go @@ -0,0 +1,282 @@ +package services + +import ( + "fmt" + "math" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/dgrijalva/jwt-go/request" + "xorm.io/xorm" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/datastore" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/settings" + "github.com/mayswind/lab/pkg/utils" +) + +type TokenService struct { + ServiceUsingDB + ServiceUsingConfig +} + +var ( + Tokens = &TokenService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + ServiceUsingConfig: ServiceUsingConfig{ + container: settings.Container, + }, + } +) + +func (s *TokenService) GetAllTokensByUid(uid int64) ([]*models.TokenRecord, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + var tokenRecords []*models.TokenRecord + err := s.TokenDB(uid).Cols("uid", "user_token_id", "token_type", "user_agent", "created_unix_time", "expired_unix_time").Where("uid=?", uid).OrderBy("created_unix_time desc").Find(&tokenRecords) + + return tokenRecords, err +} + +func (s *TokenService) ParseToken(c *core.Context) (*jwt.Token, *core.UserTokenClaims, error) { + claims := &core.UserTokenClaims{} + + token, err := request.ParseFromRequest(c.Request, request.AuthorizationHeaderExtractor, + func (token *jwt.Token) (interface{}, error) { + uid, err := utils.StringToInt64(claims.Id) + now := time.Now().Unix() + + if err != nil { + log.WarnfWithRequestId(c, "[tokens.ParseToken] user \"uid:%s\" in token is invalid, because %s", claims.Id, err.Error()) + return nil, errs.ErrInvalidToken + } + + userTokenId, err := utils.StringToInt64(claims.UserTokenId) + + if err != nil { + log.WarnfWithRequestId(c, "[tokens.ParseToken] token \"utid:%s\" in token of user \"uid:%s\" is invalid, because %s", claims.UserTokenId, claims.Id, err.Error()) + return nil, errs.ErrInvalidUserTokenId + } + + tokenRecord, err := s.getTokenRecord(uid, userTokenId, claims.IssuedAt) + + if err != nil { + log.WarnfWithRequestId(c, "[tokens.ParseToken] token \"utid:%s\" of user \"uid:%s\" record not found, because %s", claims.UserTokenId, claims.Id, err.Error()) + return nil, errs.ErrTokenRecordNotFound + } + + if tokenRecord.ExpiredUnixTime < now { + log.WarnfWithRequestId(c, "[tokens.ParseToken] token \"utid:%s\" of user \"uid:%s\" record is expired", claims.UserTokenId, claims.Id) + return nil, errs.ErrTokenExpired + } + + return []byte(tokenRecord.Secret), nil + }, request.WithClaims(claims)) + + if err != nil { + return nil, nil, err + } + + return token, claims, err +} + +func (s *TokenService) CreateToken(user *models.User, ctx *core.Context) (string, *core.UserTokenClaims, error) { + return s.createToken(user, core.USER_TOKEN_TYPE_NORMAL, s.getUserAgent(ctx), s.CurrentConfig().TokenExpiredTimeDuration) +} + +func (s *TokenService) CreateRequire2FAToken(user *models.User, ctx *core.Context) (string, *core.UserTokenClaims, error) { + return s.createToken(user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(ctx), s.CurrentConfig().TemporaryTokenExpiredTimeDuration) +} + +func (s *TokenService) DeleteToken(tokenRecord *models.TokenRecord) error { + if tokenRecord.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + if tokenRecord.UserTokenId <= 0 { + return errs.ErrInvalidUserTokenId + } + + return s.TokenDB(tokenRecord.Uid).DoTranscation(func(sess *xorm.Session) error { + deletedRows, err := sess.Where("uid=? AND user_token_id=? AND created_unix_time=?", tokenRecord.Uid, tokenRecord.UserTokenId, tokenRecord.CreatedUnixTime).Delete(&models.TokenRecord{}) + + if deletedRows < 1 { + return errs.ErrTokenRecordNotFound + } + + return err + }) +} + +func (s *TokenService) DeleteTokenByClaims(claims *core.UserTokenClaims) error { + uid, err := utils.StringToInt64(claims.Id) + + if err != nil { + return errs.ErrUserIdInvalid + } + + userTokenId, err := utils.StringToInt64(claims.UserTokenId) + + if err != nil { + return errs.ErrInvalidUserTokenId + } + + return s.DeleteToken(&models.TokenRecord{Uid: uid, UserTokenId: userTokenId, CreatedUnixTime: claims.IssuedAt}) +} + +func (s *TokenService) DeleteTokensBeforeTime(uid int64, expireTime int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.TokenDB(uid).DoTranscation(func(sess *xorm.Session) error { + _, err := sess.Where("uid=? AND created_unix_time models.TOKEN_USER_AGENT_MAX_LENGTH { + userAgent = utils.SubString(userAgent, 0, models.TOKEN_USER_AGENT_MAX_LENGTH) + } + + return userAgent +} diff --git a/pkg/services/twofactor_authorizations.go b/pkg/services/twofactor_authorizations.go new file mode 100644 index 00000000..eec74b3b --- /dev/null +++ b/pkg/services/twofactor_authorizations.go @@ -0,0 +1,204 @@ +package services + +import ( + "time" + "xorm.io/xorm" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + + "github.com/mayswind/lab/pkg/datastore" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/settings" + "github.com/mayswind/lab/pkg/utils" + "github.com/mayswind/lab/pkg/uuid" +) + +const ( + TWOFACTOR_PERIOD uint = 30 // seconds + TWOFACTOR_SECRET_SIZE uint = 20 // bytes + TWOFACTOR_RECOVERY_CODE_COUNT int = 10 + TWOFACTOR_RECOVERY_CODE_LENGTH int = 10 // bytes +) + +type TwoFactorAuthorizationService struct { + ServiceUsingDB + ServiceUsingConfig + ServiceUsingUuid +} + +var ( + TwoFactorAuthorizations = &TwoFactorAuthorizationService{ + ServiceUsingDB: ServiceUsingDB{ + container: datastore.Container, + }, + ServiceUsingConfig: ServiceUsingConfig{ + container: settings.Container, + }, + ServiceUsingUuid: ServiceUsingUuid{ + container: uuid.Container, + }, + } +) + +func (s *TwoFactorAuthorizationService) GetUserTwoFactorSettingByUid(uid int64) (*models.TwoFactor, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + twoFactor := &models.TwoFactor{} + has, err := s.UserDB().Where("uid=?", uid).Get(twoFactor) + + if err != nil { + return nil, err + } else if !has { + return nil, errs.ErrTwoFactorKeyIsNotEnabled + } + + twoFactor.Secret, err = utils.DecryptSecret(twoFactor.Secret, s.CurrentConfig().SecretKey) + + if err != nil { + return nil, err + } + + return twoFactor, nil +} + +func (s *TwoFactorAuthorizationService) GenerateTwoFactorSecret(user *models.User) (*otp.Key, error) { + if user == nil { + return nil, errs.ErrUserNotFound + } + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: s.CurrentConfig().AppName, + AccountName: user.Username, + Period: TWOFACTOR_PERIOD, + SecretSize: TWOFACTOR_SECRET_SIZE, + }) + + return key, err +} + +func (s *TwoFactorAuthorizationService) CreateTwoFactorSetting(twoFactor *models.TwoFactor) error { + if twoFactor.Uid <= 0 { + return errs.ErrUserIdInvalid + } + + var err error + twoFactor.Secret, err = utils.EncyptSecret(twoFactor.Secret, s.CurrentConfig().SecretKey) + + if err != nil { + return err + } + + twoFactor.CreatedUnixTime = time.Now().Unix() + + return s.UserDB().DoTranscation(func(sess *xorm.Session) error { + _, err := sess.Insert(twoFactor) + return err + }) +} + +func (s *TwoFactorAuthorizationService) DeleteTwoFactorSetting(uid int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.UserDB().DoTranscation(func(sess *xorm.Session) error { + deletedRows, err := sess.Where("uid=?", uid).Delete(&models.TwoFactor{}) + + if deletedRows < 1 { + return errs.ErrTwoFactorKeyIsNotEnabled + } + + return err + }) +} + +func (s *TwoFactorAuthorizationService) ExistsTwoFactorSetting(uid int64) (bool, error) { + if uid <= 0 { + return false, errs.ErrUserIdInvalid + } + + return s.UserDB().Cols("uid").Where("uid=?", uid).Exist(&models.TwoFactor{}) +} + +func (s *TwoFactorAuthorizationService) GetAndUseUserTwoFactorRecoveryCode(uid int64, recoveryCode string, salt string) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + recoveryCode = utils.EncodePassword(recoveryCode, salt) + exists, err := s.UserDB().Cols("uid", "recovery_code").Where("uid=? AND recovery_code=? AND used=?", uid, recoveryCode, false).Exist(&models.TwoFactorRecoveryCode{}) + + if err != nil { + return err + } else if !exists { + return errs.ErrTwoFactorRecoveryCodeNotExist + } + + return s.UserDB().DoTranscation(func(sess *xorm.Session) error { + _, err := sess.Cols("used", "used_unix_time").Where("uid=? AND recovery_code=?", uid, recoveryCode).Update(&models.TwoFactorRecoveryCode{Used: true, UsedUnixTime: time.Now().Unix()}) + return err + }) +} + +func (s *TwoFactorAuthorizationService) GenerateTwoFactorRecoveryCodes() ([]string, error) { + recoveryCodes := make([]string, TWOFACTOR_RECOVERY_CODE_COUNT) + + for i := 0; i < TWOFACTOR_RECOVERY_CODE_COUNT; i++ { + recoveryCode, err := utils.GetRandomNumberOrLetter(TWOFACTOR_RECOVERY_CODE_LENGTH) + + if err != nil { + return nil, err + } + + recoveryCodes[i] = recoveryCode[:5] + "-" + recoveryCode[5:] + } + + return recoveryCodes, nil +} + +func (s *TwoFactorAuthorizationService) CreateTwoFactorRecoveryCodes(uid int64, recoveryCodes []string, salt string) error { + twoFactorRecoveryCodes := make([]*models.TwoFactorRecoveryCode, len(recoveryCodes)) + + for i := 0; i < len(recoveryCodes); i++ { + twoFactorRecoveryCodes[i] = &models.TwoFactorRecoveryCode{ + Uid: uid, + Used: false, + RecoveryCode: utils.EncodePassword(recoveryCodes[i], salt), + CreatedUnixTime: time.Now().Unix(), + } + } + + return s.UserDB().DoTranscation(func(sess *xorm.Session) error { + _, err := sess.Where("uid=?", uid).Delete(&models.TwoFactorRecoveryCode{}) + + if err != nil { + return err + } + + for i := 0; i < len(twoFactorRecoveryCodes); i++ { + twoFactorRecoveryCode := twoFactorRecoveryCodes[i] + _, err := sess.Insert(twoFactorRecoveryCode) + + if err != nil { + return err + } + } + + return nil + }) +} + +func (s *TwoFactorAuthorizationService) DeleteTwoFactorRecoveryCodes(uid int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.UserDB().DoTranscation(func(sess *xorm.Session) error { + _, err := sess.Where("uid=?", uid).Delete(&models.TwoFactorRecoveryCode{}) + return err + }) +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..1f53798b --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /