From 26a1cb81303ba4b9ea40b5a8eed6ed4c7e8565e3 Mon Sep 17 00:00:00 2001 From: caixw Date: Mon, 28 Oct 2024 23:05:36 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(cmfx/modules/admin):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=99=BB=E5=BD=95=E9=AA=8C=E8=AF=81=E7=9A=84=E5=90=84?= =?UTF-8?q?=E7=B1=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除了原来与修改密码相关的接口,统一由登录验证接口管理。 --- cmfx/cmfx.go | 3 + cmfx/initial/cmd/cmd.go | 8 +- cmfx/modules/admin/models.go | 8 +- cmfx/modules/admin/module.go | 21 +- cmfx/modules/admin/route_admins.go | 76 +++----- cmfx/modules/admin/route_auth.go | 287 ++++++++++++++++++++++++++++ cmfx/modules/admin/route_current.go | 45 +---- cmfx/modules/admin/route_token.go | 66 ------- cmfx/user/passport/adapter.go | 2 +- 9 files changed, 340 insertions(+), 176 deletions(-) create mode 100644 cmfx/modules/admin/route_auth.go delete mode 100644 cmfx/modules/admin/route_token.go diff --git a/cmfx/cmfx.go b/cmfx/cmfx.go index 731ecab3..47e6284b 100644 --- a/cmfx/cmfx.go +++ b/cmfx/cmfx.go @@ -58,4 +58,7 @@ const ( ForbiddenCaNotDeleteYourself = "40302" ) +// 404 +const NotFound = web.ProblemNotFound + func ErrNotFound() error { return locales.ErrNotFound() } diff --git a/cmfx/initial/cmd/cmd.go b/cmfx/initial/cmd/cmd.go index ac7392a1..3adacd24 100644 --- a/cmfx/initial/cmd/cmd.go +++ b/cmfx/initial/cmd/cmd.go @@ -7,7 +7,9 @@ package cmd import ( "flag" + "net/smtp" "path/filepath" + "time" "github.com/issue9/upload/v3" "github.com/issue9/web" @@ -24,7 +26,7 @@ import ( "github.com/issue9/cmfx/cmfx/initial" "github.com/issue9/cmfx/cmfx/modules/admin" "github.com/issue9/cmfx/cmfx/modules/system" - "github.com/issue9/cmfx/cmfx/user/passport/password" + "github.com/issue9/cmfx/cmfx/user/passport/code" ) func Exec(name, version string) error { @@ -84,7 +86,9 @@ func initServer(name, ver string, o *server.Options, user *Config, action string switch action { case "serve": adminL := admin.Load(adminMod, user.Admin, uploadSaver) - adminL.Passport().Register("password2", password.New(adminL.Module(), "password2", 5), web.Phrase("another password valid")) + smtpAuth := smtp.PlainAuth("id", "username", "password", "smtp@example.com") + smtpAdpater := code.New(adminL.Module(), 5*time.Minute, "smtp", code.NewSMTPSender("code", "smtp@example.com", "server@example.com", "%%code%%", smtpAuth)) + adminL.Passport().Register("smtp", smtpAdpater, web.Phrase("smtp valid")) system.Load(systemMod, user.System, adminL) case "install": diff --git a/cmfx/modules/admin/models.go b/cmfx/modules/admin/models.go index c6d99f16..30959ba4 100644 --- a/cmfx/modules/admin/models.go +++ b/cmfx/modules/admin/models.go @@ -62,20 +62,22 @@ func (i *info) Filter(v *web.FilterContext) { } func (i *ctxInfoWithRoleState) Filter(v *web.FilterContext) { - i.roles = make([]*rbac.Role, 0, len(i.Roles)) - roleValidator := func(id string) bool { r := i.m.roleGroup.Role(id) if r == nil { return false } - i.roles = append(i.roles, r) return !r.IsDescendant(id) } i.info.Filter(v) v.Add(filter.NewBuilder(filter.SV[[]string](roleValidator, locales.InvalidValue))("roles", &i.Roles)). Add(user.StateFilter("state", &i.State)) + + i.roles = make([]*rbac.Role, 0, len(i.Roles)) + for _, id := range i.Roles { + i.roles = append(i.roles, i.m.roleGroup.Role(id)) + } } func (i *infoWithAccountDTO) Filter(v *web.FilterContext) { diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index a6580efb..76bca0a1 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -171,8 +171,7 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { QueryObject(user.QueryLogDTO{}, nil). Desc(web.Phrase("get login user security log api"), nil). Response("200", user.LogVO{}, nil, nil) - })). - Put("/password", m.putCurrentPassword) + })) mod.Router().Prefix(m.URLPrefix(), m). Get("/admins", m.getAdmins, getAdmin, mod.API(func(o *openapi.Operation) { @@ -203,13 +202,6 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { Body(&ctxInfoWithRoleState{}, false, nil, nil). ResponseRef("204", "empty", nil, nil) })). - Delete("/admins/{id:digit}/password", m.deleteAdminPassword, putAdmin, mod.API(func(o *openapi.Operation) { - o.Tag("admin"). - Desc(web.Phrase("reset admin password api"), nil). - PathID("id:digit", web.Phrase("the ID of admin")). - Body(&ctxInfoWithRoleState{}, false, nil, nil). - ResponseRef("204", "empty", nil, nil) - })). Post("/admins/{id:digit}/locked", m.postAdminLocked, putAdmin, mod.API(func(o *openapi.Operation) { o.Tag("admin"). Desc(web.Phrase("lock the admin api"), nil). @@ -227,6 +219,17 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { ResponseRef("204", "empty", nil, nil) })) + // passport + mod.Router().Prefix(m.URLPrefix(), m). + Delete("/passports/{type}", m.deletePassport). + Post("/passports/{type}", m.postPassport). + Patch("/passports/{type}", m.patchPassport). + Put("/passports/{type}", m.putPassport). + Delete("/admins/{id:digit}/passports/{type}", m.deleteAdminPassport). + Post("/admins/{id:digit}/passports/{type}", m.postAdminPassport). + Patch("/admins/{id:digit}/passports/{type}", m.patchAdminPassport). + Put("/admins/{id:digit}/passports/{type}", m.putAdminPassport) + // upload up := upload.New(saver, o.Upload.Size, o.Upload.Exts...) mod.Router().Prefix(m.URLPrefix()). diff --git a/cmfx/modules/admin/route_admins.go b/cmfx/modules/admin/route_admins.go index 92d0fa69..65a297f5 100644 --- a/cmfx/modules/admin/route_admins.go +++ b/cmfx/modules/admin/route_admins.go @@ -6,7 +6,6 @@ package admin import ( "cmp" - "errors" "net/http" "slices" "time" @@ -49,14 +48,13 @@ func (m *Module) getAdmin(ctx *web.Context) web.Responser { return ctx.NotFound() } - roles := m.roleGroup.UserRoles(id) u, err := m.user.GetUser(id) if err != nil { return ctx.Error(err, "") } - rs := make([]string, 0, len(roles)) - for _, r := range roles { + rs := make([]string, 0, 4) + for _, r := range m.roleGroup.UserRoles(id) { rs = append(rs, r.ID) } @@ -150,75 +148,47 @@ func (m *Module) getAdmins(ctx *web.Context) web.Responser { } func (m *Module) patchAdmin(ctx *web.Context) web.Responser { - id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) + u, resp := m.getActiveUserFromContext(ctx) if resp != nil { return resp } - u, err := m.user.GetUser(id) - if err != nil { - return ctx.Error(err, "") - } - // 读取数据 - data := &ctxInfoWithRoleState{} + data := &ctxInfoWithRoleState{info: info{m: m}} if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { return resp } - data.ID = id // 指定主键 + data.ID = u.ID // 指定主键 + + err := m.Module().DB().DoTransaction(func(tx *orm.Tx) error { + e := tx.NewEngine(m.Module().DB().TablePrefix()) + if _, err := e.Update(&data.info, "sex"); err != nil { + return err + } - tx, err := m.Module().DB().Begin() + return m.user.SetState(tx, u, data.State) + }) if err != nil { return ctx.Error(err, "") } - e := tx.NewEngine(m.Module().DB().TablePrefix()) - if _, err := e.Update(data, "sex"); err != nil { - return ctx.Error(errors.Join(err, tx.Rollback()), "") + // 取消所有的权限组,可能涉及数据库操作,在事务外执行。 + for _, role := range m.roleGroup.UserRoles(u.ID) { + if err := role.Unlink(u.ID); err != nil { + return ctx.Error(err, "") + } } - for _, rid := range data.Roles { - r := m.roleGroup.Role(rid) - if r == nil { - continue + role := m.roleGroup.Role(rid) + if role == nil { // 由 filter 保证不存在为 nil + panic("role == nil") } - if err := r.Link(id); err != nil { - return ctx.Error(errors.Join(err, tx.Rollback()), "") + if err := role.Link(u.ID); err != nil { + return ctx.Error(err, "") } } - if err := m.user.SetState(tx, u, data.State); err != nil { - return ctx.Error(errors.Join(err, tx.Rollback()), "") - } - - if err := tx.Commit(); err != nil { - return ctx.Error(err, "") - } - - return web.NoContent() -} - -func (m *Module) deleteAdminPassword(ctx *web.Context) web.Responser { - id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) - if resp != nil { - return resp - } - - // 查看指定的用户是否真实存在,不判断状态,即使锁定,也能改其信息 - u, err := m.user.GetUser(id) - if err != nil { - return ctx.Error(err, "") - } - if u.State != user.StateNormal { - return ctx.Problem(cmfx.ForbiddenStateNotAllow) - } - - // 更新数据库 - if err := m.Passport().Get(passportTypePassword).Set(id, m.defaultPassword); err != nil { - return ctx.Error(err, "") - } - return web.NoContent() } diff --git a/cmfx/modules/admin/route_auth.go b/cmfx/modules/admin/route_auth.go new file mode 100644 index 00000000..4a41b264 --- /dev/null +++ b/cmfx/modules/admin/route_auth.go @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +package admin + +import ( + "cmp" + "slices" + + "github.com/issue9/web" + "github.com/issue9/web/filter" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/locales" + "github.com/issue9/cmfx/cmfx/user" + "github.com/issue9/cmfx/cmfx/user/passport" +) + +type queryLogin struct { + m *Module + Type string `query:"type,password"` +} + +func (q *queryLogin) Filter(c *web.FilterContext) { + v := func(s string) bool { return q.m.Passport().Get(s) != nil } + c.Add(filter.NewBuilder(filter.V(v, locales.InvalidValue))("type", &q.Type)) +} + +// # API POST /login 管理员登录 +// @tag admin auth +// @query queryLogin +// @req * github.com/issue9/cmfx/cmfx/user.reqAccount +// @resp 201 * github.com/issue9/webuse/v7/middlewares/auth/token.Response +func (m *Module) postLogin(ctx *web.Context) web.Responser { + q := &queryLogin{m: m} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + return m.user.Login(q.Type, ctx, nil, func(u *user.User) { + m.loginEvent.Publish(false, u) + }) +} + +// # api delete /login 注销当前管理员的登录 +// @tag admin auth +// @resp 204 * {} +func (m *Module) deleteLogin(ctx *web.Context) web.Responser { + return m.user.Logout(ctx, func(u *user.User) { + m.logoutEvent.Publish(false, u) + }, web.StringPhrase("logout")) +} + +// # api put /login 续定令牌 +// @tag admin auth +// @resp 201 * github.com/issue9/webuse/v7/middlewares/auth/token.Response +func (m *Module) putToken(ctx *web.Context) web.Responser { + return m.user.RefreshToken(ctx) +} + +type respAdapters struct { + Name string `json:"name" cbor:"name" xml:"name"` + Desc string `json:"desc" cbor:"desc" xml:"desc"` +} + +// # api GET /passports 支持的登录验证方式 +// #tag admin auth +// @resp 200 * respAdapters +func (m *Module) getPassports(ctx *web.Context) web.Responser { + adapters := make([]*respAdapters, 0) + for k, v := range m.Passport().All(ctx.LocalePrinter()) { + adapters = append(adapters, &respAdapters{ + Name: k, + Desc: v, + }) + } + slices.SortFunc(adapters, func(a, b *respAdapters) int { return cmp.Compare(a.Name, b.Name) }) + + return web.OK(adapters) +} + +// # api delete /passports/{type} 取消当前用户与登录方式 type 之间的关联 +// #tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @resp 204 * {} +func (m *Module) deletePassport(ctx *web.Context) web.Responser { + return m.delAdminPassport(ctx, m.getPassport) +} + +// # api delete /admins/{id}/passports/{type} 取消用户 id 与登录方式 type 之间的关联 +// #tag admin auth +// @path id id 管理员的 ID +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @resp 204 * {} +func (m *Module) deleteAdminPassport(ctx *web.Context) web.Responser { + return m.delAdminPassport(ctx, m.getAdminPassport) +} + +func (m *Module) delAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { + u, a, resp := f(ctx) + if resp != nil { + return resp + } + + if err := a.Delete(u.ID); err != nil { + return ctx.Error(err, "") + } + return web.NoContent() +} + +type reqPassport struct { + ID string `json:"id" cbor:"id" xml:"id"` + Code string `json:"code" cbor:"code" xml:"code"` +} + +// # api POST /passports/{type} 建立当前用户与登录方式 type 之间的关联 +// #tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqPassport +// @resp 201 * {} +func (m *Module) postPassport(ctx *web.Context) web.Responser { + return m.addAdminPassport(ctx, m.getPassport) +} + +// # api POST /admins/{id}/passports/{type} 建立用户 id 与登录方式 type 之间的关联 +// #tag admin auth +// @path id id 管理员的 ID +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqPassport +// @resp 201 * {} +func (m *Module) postAdminPassport(ctx *web.Context) web.Responser { + return m.addAdminPassport(ctx, m.getAdminPassport) +} + +func (m *Module) addAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { + u, a, resp := f(ctx) + if resp != nil { + return resp + } + + data := &reqPassport{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + if err := a.Add(u.ID, data.ID, data.Code, ctx.Begin()); err != nil { + return ctx.Error(err, "") + } + + return web.Created(nil, "") +} + +type reqChangePassport struct { + New string `json:"new" cbor:"new" xml:"new"` + Old string `json:"old" cbor:"old" xml:"old"` +} + +// # api patch /passports/{type} 修改当前用户的登录方式 type 的认证数据 +// #tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqChangePassport +// @resp 204 * {} +func (m *Module) patchPassport(ctx *web.Context) web.Responser { + return m.editAdminPassport(ctx, m.getPassport) +} + +// # api patch /admins/{id}/passports/{type} 修改用户 id 的登录方式 type 的认证数据 +// #tag admin auth +// @path id id 管理员的 ID +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqChangePassport +// @resp 204 * {} +func (m *Module) patchAdminPassport(ctx *web.Context) web.Responser { + return m.editAdminPassport(ctx, m.getAdminPassport) +} + +func (m *Module) editAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { + u, a, resp := f(ctx) + if resp != nil { + return resp + } + + data := &reqChangePassport{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + if err := a.Change(u.ID, data.Old, data.New); err != nil { + return ctx.Error(err, "") + } + + return web.NoContent() +} + +type reqSetPassport struct { + New string `json:"new" cbor:"new" xml:"new"` +} + +// # api PUT /passports/{type} 替换当前用户登录方式 type 的认证数据 +// #tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqSetPassport +// @resp 204 * {} +func (m *Module) putPassport(ctx *web.Context) web.Responser { + return m.setAdminPassport(ctx, m.getPassport) +} + +// # api PUT /admins/{id}/passports/{type} 替换用户 id 的登录方式 type 的认证数据 +// #tag admin auth +// @path id id 管理员的 ID +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @req * reqSetPassport +// @resp 204 * {} +func (m *Module) putAdminPassport(ctx *web.Context) web.Responser { + return m.setAdminPassport(ctx, m.getAdminPassport) +} + +func (m *Module) setAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { + u, a, resp := f(ctx) + if resp != nil { + return resp + } + + data := &reqSetPassport{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + if err := a.Set(u.ID, data.New); err != nil { + return ctx.Error(err, "") + } + + return web.NoContent() +} + +func (m *Module) getPassport(ctx *web.Context) (*user.User, passport.Adapter, web.Responser) { + u := m.CurrentUser(ctx) + + typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) + if resp != nil { + return nil, nil, resp + } + + a := m.Passport().Get(typ) + if a == nil { + return nil, nil, ctx.Problem(cmfx.NotFound) + } + + return u, a, nil +} + +func (m *Module) getAdminPassport(ctx *web.Context) (*user.User, passport.Adapter, web.Responser) { + u, resp := m.getActiveUserFromContext(ctx) + if resp != nil { + return nil, nil, resp + } + + typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) + if resp != nil { + return nil, nil, resp + } + + a := m.Passport().Get(typ) + if a == nil { + return nil, nil, ctx.Problem(cmfx.NotFound) + } + + return u, a, nil +} + +func (m *Module) getActiveUserFromContext(ctx *web.Context) (*user.User, web.Responser) { + id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) + if resp != nil { + return nil, resp + } + + u, err := m.user.GetUser(id) + if err != nil { + return nil, ctx.Error(err, "") + } + if u.State == user.StateDeleted { + return nil, ctx.Problem(cmfx.ForbiddenStateNotAllow) + } + + return u, nil +} diff --git a/cmfx/modules/admin/route_current.go b/cmfx/modules/admin/route_current.go index a1e577f4..493c4566 100644 --- a/cmfx/modules/admin/route_current.go +++ b/cmfx/modules/admin/route_current.go @@ -5,14 +5,9 @@ package admin import ( - "errors" - "github.com/issue9/web" - "github.com/issue9/web/filter" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/filters" - "github.com/issue9/cmfx/cmfx/user/passport" ) func (m *Module) getInfo(ctx *web.Context) web.Responser { @@ -49,44 +44,10 @@ func (m *Module) patchInfo(ctx *web.Context) web.Responser { return web.NoContent() } -// 密码修改 -type reqPassword struct { - XMLName struct{} `json:"-" xml:"password" cbor:"-"` - Old string `json:"old" xml:"old" cbor:"old"` - New string `json:"new" xml:"new" cbor:"new"` -} - -func (p *reqPassword) Filter(v *web.FilterContext) { - same := filter.V(func(s string) bool { - return s != p.Old - }, web.StringPhrase("same of new and old password")) - - v.Add(filters.NotEmpty("old", &p.Old)). - Add(filters.NotEmpty("new", &p.New)). - Add(filter.NewBuilder(same)("new", &p.New)) -} - -// # api PUT /password 当前登录用户修改自己的密码 +// # api get /securitylog 当前用户的安全操作记录 // @tag admin -// @req * reqPassword -// @resp 204 * {} -func (m *Module) putCurrentPassword(ctx *web.Context) web.Responser { - data := &reqPassword{} - if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { - return resp - } - - a := m.CurrentUser(ctx) - err := m.Passport().Get(passportTypePassword).Change(a.ID, data.Old, data.New) - if errors.Is(err, passport.ErrUnauthorized()) { - return ctx.Problem(cmfx.Unauthorized) - } else if err != nil { - return ctx.Error(err, "") - } - - return m.user.Logout(ctx, nil, web.StringPhrase("change password")) -} - +// @query github.com/issue9/cmfx/cmfx/user.queryLog +// @resp 200 * github.com/issue9/cmfx/cmfx/query.Page[github.com/issue9/cmfx/cmfx/user.respLog] func (m *Module) getSecurityLogs(ctx *web.Context) web.Responser { return m.user.GetSecurityLogs(ctx) } diff --git a/cmfx/modules/admin/route_token.go b/cmfx/modules/admin/route_token.go deleted file mode 100644 index 52677a15..00000000 --- a/cmfx/modules/admin/route_token.go +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package admin - -import ( - "cmp" - "slices" - - "github.com/issue9/web" - "github.com/issue9/web/filter" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/locales" - "github.com/issue9/cmfx/cmfx/user" -) - -type queryLogin struct { - m *Module - Type string `query:"type,password"` -} - -func (q *queryLogin) Filter(c *web.FilterContext) { - v := func(s string) bool { return q.m.Passport().Get(s) != nil } - c.Add(filter.NewBuilder(filter.V(v, locales.InvalidValue))("type", &q.Type)) -} - -func (m *Module) postLogin(ctx *web.Context) web.Responser { - q := &queryLogin{m: m} - if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { - return resp - } - - return m.user.Login(q.Type, ctx, nil, func(u *user.User) { - m.loginEvent.Publish(false, u) - }) -} - -func (m *Module) deleteLogin(ctx *web.Context) web.Responser { - return m.user.Logout(ctx, func(u *user.User) { - m.logoutEvent.Publish(false, u) - }, web.StringPhrase("logout")) -} - -func (m *Module) putToken(ctx *web.Context) web.Responser { - return m.user.RefreshToken(ctx) -} - -type respAdapters struct { - Name string `json:"name" cbor:"name" xml:"name" comment:"passport adapter id"` - Desc string `json:"desc" cbor:"desc" xml:"desc" comment:"passport adapter description"` -} - -func (m *Module) getPassports(ctx *web.Context) web.Responser { - adapters := make([]*respAdapters, 0) - for k, v := range m.Passport().All(ctx.LocalePrinter()) { - adapters = append(adapters, &respAdapters{ - Name: k, - Desc: v, - }) - } - slices.SortFunc(adapters, func(a, b *respAdapters) int { return cmp.Compare(a.Name, b.Name) }) - - return web.OK(adapters) -} diff --git a/cmfx/user/passport/adapter.go b/cmfx/user/passport/adapter.go index b40aac92..71f4a619 100644 --- a/cmfx/user/passport/adapter.go +++ b/cmfx/user/passport/adapter.go @@ -41,7 +41,7 @@ type Adapter interface { // Set 强制修改用户 uid 的认证数据 // // uid 为需要操作的用户,不能为零; - // n 的定义与 [Adapter.Change] 是相同的。 + // n 为新的认证数据,由用户自定义,一般为新密码或是新的设备 ID 等; Set(uid int64, n string) error // Add 关联用户数据 From fd75ae9b12b7397c0b895f3134df7af23ae722fc Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 29 Oct 2024 09:44:38 +0800 Subject: [PATCH 02/11] =?UTF-8?q?refactor(cmfx/user/passport):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20passport=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 adapter 和 Adapter 合并为一; - 删除了 Adapter.Change; --- cmfx/initial/cmd/cmd.go | 4 +- cmfx/locales/zh.yaml | 8 +-- cmfx/modules/admin/module.go | 4 +- cmfx/user/passport/adapter.go | 29 ++++---- cmfx/user/passport/adaptertest/adaptertest.go | 17 ++--- cmfx/user/passport/code/code.go | 48 ++++++------- cmfx/user/passport/code/code_test.go | 9 ++- cmfx/user/passport/code/gen.go | 23 ++++++ cmfx/user/passport/errors.go | 3 + cmfx/user/passport/oauth/oauth.go | 26 +++---- cmfx/user/passport/passport.go | 71 +++++++++++-------- cmfx/user/passport/passport_test.go | 30 ++++---- cmfx/user/passport/password/password.go | 67 ++++------------- cmfx/user/passport/password/password_test.go | 7 +- cmfx/user/token_test.go | 2 +- go.mod | 2 +- 16 files changed, 174 insertions(+), 176 deletions(-) create mode 100644 cmfx/user/passport/code/gen.go diff --git a/cmfx/initial/cmd/cmd.go b/cmfx/initial/cmd/cmd.go index 3adacd24..5f289df2 100644 --- a/cmfx/initial/cmd/cmd.go +++ b/cmfx/initial/cmd/cmd.go @@ -87,8 +87,8 @@ func initServer(name, ver string, o *server.Options, user *Config, action string case "serve": adminL := admin.Load(adminMod, user.Admin, uploadSaver) smtpAuth := smtp.PlainAuth("id", "username", "password", "smtp@example.com") - smtpAdpater := code.New(adminL.Module(), 5*time.Minute, "smtp", code.NewSMTPSender("code", "smtp@example.com", "server@example.com", "%%code%%", smtpAuth)) - adminL.Passport().Register("smtp", smtpAdpater, web.Phrase("smtp valid")) + smtpAdpater := code.New(adminL.Module(), 5*time.Minute, "smtp", nil, code.NewSMTPSender("code", "smtp@example.com", "server@example.com", "%%code%%", smtpAuth), web.Phrase("smtp valid")) + adminL.Passport().Register(smtpAdpater) system.Load(systemMod, user.System, adminL) case "install": diff --git a/cmfx/locales/zh.yaml b/cmfx/locales/zh.yaml index 57b1f4e3..487be6c3 100644 --- a/cmfx/locales/zh.yaml +++ b/cmfx/locales/zh.yaml @@ -265,13 +265,7 @@ messages: msg: 登录验证器的 ID - key: password mode message: - msg: 密码模式 - - key: patch admin info api - message: - msg: 更新管理员信息 - - key: patch login user info api - message: - msg: 更新当前登录用户的信息 + msg: 密码登录 - key: post admins message: msg: 添加管理员 diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index 76bca0a1..f661b5c3 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -28,7 +28,7 @@ import ( "github.com/issue9/cmfx/cmfx/user/rbac" ) -const passportTypePassword = "password" // 采用密码登录的 +const passportTypePassword = "passwords" // 采用密码登录的 type Module struct { user *user.Module @@ -52,7 +52,7 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { u := user.Load(mod, o.User) - u.Passport().Register(passportTypePassword, password.New(mod, "passwords", 8), web.StringPhrase("password mode")) + u.Passport().Register(password.New(mod, passportTypePassword, 8, web.StringPhrase("password mode"))) m := &Module{ user: u, defaultPassword: o.DefaultPassword, diff --git a/cmfx/user/passport/adapter.go b/cmfx/user/passport/adapter.go index 71f4a619..49820d0e 100644 --- a/cmfx/user/passport/adapter.go +++ b/cmfx/user/passport/adapter.go @@ -4,10 +4,20 @@ package passport -import "time" +import ( + "time" -// Adapter 身份验证适配器 + "github.com/issue9/web" +) + +// Adapter 身份验证的适配器 type Adapter interface { + // ID 该适配器对象的唯一标记 + ID() string + + // Description 对当前实例的描述信息 + Description() web.LocaleStringer + // Valid 验证账号 // // username, password 向验证器提供的登录凭证,不同的实现对此两者的定义可能是不同的, @@ -31,18 +41,11 @@ type Adapter interface { // 如果 uid 为零值,清空所有的临时验证数据。 Delete(uid int64) error - // Change 改变用户的认证数据 - // - // uid 为需要操作的用户,不能为零; - // pass 一般为旧的认证代码,比如密码、验证码等; - // n 为新的认证数据,由用户自定义,一般为新密码或是新的设备 ID 等; - Change(uid int64, pass, n string) error - - // Set 强制修改用户 uid 的认证数据 + // Update 更新用户的验证数据 // - // uid 为需要操作的用户,不能为零; - // n 为新的认证数据,由用户自定义,一般为新密码或是新的设备 ID 等; - Set(uid int64, n string) error + // 部分实现可能不会实现该方法,比如基于固定时间算法的验证(TOTP), + // 或是以密码形式进行验证的接口。 + Update(uid int64) error // Add 关联用户数据 // diff --git a/cmfx/user/passport/adaptertest/adaptertest.go b/cmfx/user/passport/adaptertest/adaptertest.go index d76c943b..0b0ea8c3 100644 --- a/cmfx/user/passport/adaptertest/adaptertest.go +++ b/cmfx/user/passport/adaptertest/adaptertest.go @@ -13,8 +13,8 @@ import ( "github.com/issue9/cmfx/cmfx/user/passport" ) -// Run 测试 p 的基本功能 -func Run(a *assert.Assertion, p passport.Adapter) { +// RunBase 测试 p 的基本功能 +func RunBase(a *assert.Assertion, p passport.Adapter) { // Add a.NotError(p.Add(1024, "1024", "1024", time.Now())) @@ -35,14 +35,6 @@ func Run(a *assert.Assertion, p passport.Adapter) { uid, identity, err = p.Valid("not-exists", "pass", time.Now()) // 不存在 a.Equal(err, passport.ErrUnauthorized()).Equal(identity, "").Equal(uid, 0) - // Change - - a.ErrorIs(p.Change(1025, "1024", "1024"), passport.ErrUIDNotExists()) - a.ErrorIs(p.Change(1024, "1025", "1024"), passport.ErrUnauthorized()) - a.NotError(p.Change(1024, "1024", "1025")) - uid, identity, err = p.Valid("1024", "1025", time.Now()) - a.NotError(err).Equal(identity, "1024").Equal(uid, 1024) - // Identity identity, err = p.Identity(1024) @@ -59,3 +51,8 @@ func Run(a *assert.Assertion, p passport.Adapter) { identity, err = p.Identity(1024) a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) } + +func RunUpdate(a *assert.Assertion, p passport.Adapter) { + a.ErrorIs(p.Update(1025), passport.ErrUIDNotExists()) + a.NotError(p.Update(1024)) +} diff --git a/cmfx/user/passport/code/code.go b/cmfx/user/passport/code/code.go index dba30b91..e5dcb747 100644 --- a/cmfx/user/passport/code/code.go +++ b/cmfx/user/passport/code/code.go @@ -9,6 +9,7 @@ import ( "time" "github.com/issue9/orm/v6" + "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/user/passport" @@ -18,6 +19,9 @@ type code struct { db *orm.DB sender Sender expired time.Duration + gen Generator + id string + desc web.LocaleStringer } func buildDB(mod *cmfx.Module, tableName string) *orm.DB { @@ -26,18 +30,30 @@ func buildDB(mod *cmfx.Module, tableName string) *orm.DB { // New 声明基于验证码的验证方法 // +// id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; // expired 表示验证码的过期时间; // tableName 用于指定验证码的表名,需要在同一个 mod 环境下是唯一的; -func New(mod *cmfx.Module, expired time.Duration, tableName string, sender Sender) passport.Adapter { +func New(mod *cmfx.Module, expired time.Duration, id string, gen Generator, sender Sender, desc web.LocaleStringer) passport.Adapter { + if gen == nil { + gen = NumberGenerator(mod.Server(), id) + } + return &code{ - db: buildDB(mod, tableName), + db: buildDB(mod, id), sender: sender, expired: expired, + gen: gen, + id: id, + desc: desc, } } +func (e *code) ID() string { return e.id } + +func (e *code) Description() web.LocaleStringer { return e.desc } + func (e *code) Delete(uid int64) error { - _, err := e.db.Where("uid=?", uid).Delete(&modelCode{}) + _, err := e.db.Where("uid=?", uid).Delete(&modelCode{}) // uid == 0 也是有效值 return err } @@ -67,7 +83,7 @@ func (e *code) Identity(uid int64) (string, error) { return mod.Identity, nil } -func (e *code) Change(uid int64, pass, code string) error { +func (e *code) Update(uid int64) error { if uid == 0 { return passport.ErrUIDMustBeGreatThanZero() } @@ -76,31 +92,13 @@ func (e *code) Change(uid int64, pass, code string) error { if m == nil { return passport.ErrUIDNotExists() } - if m.Verified.Valid || m.Expired.Before(time.Now()) || m.Code != pass { - return passport.ErrUnauthorized() - } - return e.set(m.Identity, code) -} + code := e.gen() -func (e *code) Set(uid int64, code string) error { - if uid == 0 { - return passport.ErrUIDMustBeGreatThanZero() - } - - m := e.getModel(uid) - if m == nil { - return passport.ErrUIDNotExists() - } - return e.set(m.Identity, code) -} - -func (e *code) set(identity, code string) error { - if _, err := e.db.Update(&modelCode{Identity: identity, Code: code}, "code"); err != nil { + if _, err := e.db.Update(&modelCode{Identity: m.Identity, Code: code}, "code"); err != nil { return err } - - return e.sender.Sent(identity, code) + return e.sender.Sent(m.Identity, code) } // Add 注册新用户 diff --git a/cmfx/user/passport/code/code_test.go b/cmfx/user/passport/code/code_test.go index e0f183cc..e68734aa 100644 --- a/cmfx/user/passport/code/code_test.go +++ b/cmfx/user/passport/code/code_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/issue9/assert/v4" + "github.com/issue9/web" "github.com/issue9/cmfx/cmfx/initial/test" "github.com/issue9/cmfx/cmfx/user/passport" @@ -24,7 +25,11 @@ func TestCode(t *testing.T) { mod := suite.NewModule("test") Install(mod, "codes") - p := New(mod, 5*time.Minute, "codes", &sender{}) + p := New(mod, 5*time.Minute, "codes", nil, &sender{}, web.Phrase("desc")) a.NotNil(p) - adaptertest.Run(a, p) + adaptertest.RunBase(a, p) + + p = New(mod, 5*time.Minute, "codes", nil, &sender{}, web.Phrase("desc")) + a.NotError(p.Add(1024, "1024", "1024", time.Now())) + adaptertest.RunUpdate(a, p) } diff --git a/cmfx/user/passport/code/gen.go b/cmfx/user/passport/code/gen.go new file mode 100644 index 00000000..59db6d40 --- /dev/null +++ b/cmfx/user/passport/code/gen.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package code + +import ( + "github.com/issue9/rands/v3" + "github.com/issue9/web" +) + +type Generator = func() string + +func NumberGenerator(s web.Server, name string) Generator { + srv := rands.New(nil, 100, 4, 5, rands.Number()) + s.Services().Add(web.Phrase("generator code for %s", name), srv) + + return func() string { + return srv.String() + } +} + +// TODO diff --git a/cmfx/user/passport/errors.go b/cmfx/user/passport/errors.go index 84bf13f7..0995d33f 100644 --- a/cmfx/user/passport/errors.go +++ b/cmfx/user/passport/errors.go @@ -13,6 +13,7 @@ var ( errUIDNotExists = web.NewLocaleError("uid not exists") errUnauthorized = web.NewLocaleError("unauthorized") errInvalidIdentity = web.NewLocaleError("invalid identity format") + errAdapterNotFound = web.NewLocaleError("passport adapter not found") ) func ErrUIDMustBeGreatThanZero() error { return web.NewLocaleError("uid must be great than 0") } @@ -29,3 +30,5 @@ func ErrUIDNotExists() error { return errUIDNotExists } func ErrInvalidIdentity() error { return errInvalidIdentity } func ErrUnauthorized() error { return errUnauthorized } + +func ErrAdapterNotFound() error { return errAdapterNotFound } diff --git a/cmfx/user/passport/oauth/oauth.go b/cmfx/user/passport/oauth/oauth.go index 6a2d61c8..586a7b2a 100644 --- a/cmfx/user/passport/oauth/oauth.go +++ b/cmfx/user/passport/oauth/oauth.go @@ -16,7 +16,6 @@ package oauth import ( "context" - "errors" "time" "github.com/issue9/orm/v6" @@ -24,7 +23,6 @@ import ( "golang.org/x/oauth2" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/locales" "github.com/issue9/cmfx/cmfx/user/passport" ) @@ -45,6 +43,8 @@ type OAuth[T UserInfo] struct { state string config *oauth2.Config f GetUserInfoFunc[T] + id string + desc web.LocaleStringer } func buildDB(mod *cmfx.Module, tableName string) *orm.DB { @@ -52,15 +52,23 @@ func buildDB(mod *cmfx.Module, tableName string) *orm.DB { } // New 声明 [OAuth] 对象 -func New[T UserInfo](mod *cmfx.Module, tableName string, c *oauth2.Config, g GetUserInfoFunc[T]) *OAuth[T] { +// +// id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; +func New[T UserInfo](mod *cmfx.Module, id string, c *oauth2.Config, g GetUserInfoFunc[T], desc web.LocaleStringer) *OAuth[T] { return &OAuth[T]{ - db: buildDB(mod, tableName), + db: buildDB(mod, id), state: mod.Server().UniqueID(), config: c, f: g, + id: id, + desc: desc, } } +func (o *OAuth[T]) Description() web.LocaleStringer { return o.desc } + +func (o *OAuth[T]) ID() string { return o.id } + // AuthURL 返回验证地址 func (o *OAuth[T]) AuthURL() string { return o.config.AuthCodeURL(o.state) } @@ -95,7 +103,7 @@ func (o *OAuth[T]) Valid(state, code string, _ time.Time) (int64, string, error) } func (o *OAuth[T]) Delete(uid int64) error { - _, err := o.db.Delete(&modelOAuth{UID: uid}) + _, err := o.db.Where("uid=?", uid).Delete(&modelOAuth{}) // uid == 0 也是有效的值 return err } @@ -111,15 +119,9 @@ func (o *OAuth[T]) Identity(uid int64) (string, error) { return mod.Identity, nil } -func (o *OAuth[T]) Change(_ int64, _, _ string) error { return errors.ErrUnsupported } - -func (o *OAuth[T]) Set(_ int64, _ string) error { return errors.ErrUnsupported } +func (o *OAuth[T]) Update(_ int64) error { return nil } func (o *OAuth[T]) Add(uid int64, identity, _ string, now time.Time) error { - if uid == 0 { - return locales.ErrMustBeGreaterThan(0) - } - _, err := o.db.Insert(&modelOAuth{ Created: now, UID: uid, diff --git a/cmfx/user/passport/passport.go b/cmfx/user/passport/passport.go index 481bb4f8..e4113372 100644 --- a/cmfx/user/passport/passport.go +++ b/cmfx/user/passport/passport.go @@ -9,9 +9,9 @@ import ( "errors" "fmt" "iter" + "slices" "time" - "github.com/issue9/web" "golang.org/x/text/message" "github.com/issue9/cmfx/cmfx" @@ -20,45 +20,31 @@ import ( // Passport 验证器管理 type Passport struct { mod *cmfx.Module - adapters map[string]*adapter -} - -type adapter struct { - id string - name web.LocaleStringer - adapter Adapter + adapters []Adapter } // New 声明 [Passport] 对象 func New(mod *cmfx.Module) *Passport { return &Passport{ mod: mod, - adapters: make(map[string]*adapter, 5), + adapters: make([]Adapter, 0, 5), } } // Register 注册 [Adapter] -// -// id 为适配器的类型名称,需要唯一; -// name 为该适配器的本地化名称; -func (p *Passport) Register(id string, auth Adapter, name web.LocaleStringer) { - if _, found := p.adapters[id]; found { - panic(fmt.Sprintf("已经存在同名 %s 的验证器", id)) - } - - p.adapters[id] = &adapter{ - id: id, - name: name, - adapter: auth, +func (p *Passport) Register(adp Adapter) { + if slices.IndexFunc(p.adapters, func(a Adapter) bool { return a.ID() == adp.ID() }) >= 0 { + panic(fmt.Sprintf("已经存在同名 %s 的验证器", adp.ID())) } + p.adapters = append(p.adapters, adp) } // Get 返回注册的适配器 // // 如果找不到,则返回 nil。 func (p *Passport) Get(id string) Adapter { - if info, found := p.adapters[id]; found { - return info.adapter + if index := slices.IndexFunc(p.adapters, func(a Adapter) bool { return a.ID() == id }); index >= 0 { + return p.adapters[index] } return nil } @@ -67,8 +53,8 @@ func (p *Passport) Get(id string) Adapter { // // id 表示通过 [Passport.Register] 注册适配器时的 id; func (p *Passport) Valid(id, identity, password string, now time.Time) (int64, string, bool) { - if info, found := p.adapters[id]; found { - uid, ident, err := info.adapter.Valid(identity, password, now) + if a := p.Get(id); a != nil { + uid, ident, err := a.Valid(identity, password, now) switch { case errors.Is(err, ErrUnauthorized()): return 0, "", false @@ -82,11 +68,36 @@ func (p *Passport) Valid(id, identity, password string, now time.Time) (int64, s return 0, "", false } +// Set 验证并修改某个用户的验证信息 +// +// vID 用于验证的适配器 ID; +// vIdentty 用于验证的账号信息; +// vPass 用于验证的密码; +// sID 需要修改的适配器 ID; +// sIdent 需要修改的账号; +// sValue 需要修改的密码; +func (p *Passport) Set(vID, vIdent, vPass string, now time.Time, sID, sIdent, sPass string) error { + uid, _, ok := p.Valid(vID, vIdent, vPass, now) + if !ok { + return ErrUnauthorized() + } + + adp := p.Get(sID) + if adp == nil { + return ErrAdapterNotFound() + } + + if err := adp.Delete(uid); err != nil { + return err + } + return adp.Add(uid, sIdent, sPass, now) +} + // All 返回所有的适配器对象 func (p *Passport) All(printer *message.Printer) iter.Seq2[string, string] { return func(yield func(string, string) bool) { - for _, i := range p.adapters { - if !yield(i.id, i.name.LocaleString(printer)) { + for _, a := range p.adapters { + if !yield(a.ID(), a.Description().LocaleString(printer)) { break } } @@ -99,8 +110,8 @@ func (p *Passport) All(printer *message.Printer) iter.Seq2[string, string] { func (p *Passport) Identities(uid int64) iter.Seq2[string, string] { return func(yield func(string, string) bool) { for _, info := range p.adapters { - if identity, err := info.adapter.Identity(uid); err == nil { - if !yield(info.id, identity) { + if identity, err := info.Identity(uid); err == nil { + if !yield(info.ID(), identity) { break } } else { @@ -113,7 +124,7 @@ func (p *Passport) Identities(uid int64) iter.Seq2[string, string] { // ClearUser 清空与 uid 相关的所有登录信息 func (p *Passport) ClearUser(uid int64) error { for _, info := range p.adapters { - if err := info.adapter.Delete(uid); err != nil { + if err := info.Delete(uid); err != nil { return err } } diff --git a/cmfx/user/passport/passport_test.go b/cmfx/user/passport/passport_test.go index 511c3d24..3c1d4776 100644 --- a/cmfx/user/passport/passport_test.go +++ b/cmfx/user/passport/passport_test.go @@ -23,8 +23,8 @@ func TestPassport(t *testing.T) { mod1 := suite.NewModule("test_p1") mod2 := suite.NewModule("test_p2") - password.Install(mod1, "password") - password.Install(mod2, "password") + password.Install(mod1, "password1") + password.Install(mod2, "password2") p := passport.New(suite.Module()) a.NotNil(p). @@ -32,37 +32,37 @@ func TestPassport(t *testing.T) { // Register - p1 := password.New(mod1, "password", 5) - p.Register("p1", p1, web.Phrase("password")) - a.Equal(p.Get("p1"), p1) + p1 := password.New(mod1, "password1", 5, web.Phrase("password1")) + p.Register(p1) + a.Equal(p.Get("password1"), p1) - p2 := password.New(mod2, "password", 5) - p.Register("p2", p2, web.Phrase("password")) - a.Equal(p.Get("p2"), p2) + p2 := password.New(mod2, "password2", 5, web.Phrase("password2")) + p.Register(p2) + a.Equal(p.Get("password2"), p2) a.PanicString(func() { - p.Register("p1", p1, web.Phrase("password")) - }, "已经存在同名 p1 的验证器") + p.Register(password.New(mod1, "password1", 5, web.Phrase("password"))) + }, "已经存在同名 password1 的验证器") a.Length(maps.Collect(p.All(suite.Module().Server().Locale().Printer())), 2) // Valid / Identities - uid, identity, ok := p.Valid("p1", "1024", "1024", time.Now()) + uid, identity, ok := p.Valid("password1", "1024", "1024", time.Now()) a.False(ok).Equal(identity, "").Zero(uid) a.Empty(maps.Collect(p.Identities(1024))) // p1.Add a.NotError(p1.Add(1024, "1024", "1024", time.Now())) - uid, identity, ok = p.Valid("p1", "1024", "1024", time.Now()) + uid, identity, ok = p.Valid("password1", "1024", "1024", time.Now()) a.True(ok).Equal(identity, "1024").Equal(uid, 1024) - a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"p1": "1024"}) + a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"password1": "1024"}) // p2.Add a.NotError(p2.Add(1024, "1024", "1024", time.Now())) - uid, identity, ok = p.Valid("p2", "1024", "not match", time.Now()) + uid, identity, ok = p.Valid("password2", "1024", "not match", time.Now()) a.Zero(identity).Zero(uid).False(ok) - a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"p1": "1024", "p2": "1024"}) + a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"password1": "1024", "password2": "1024"}) // p.DeleteUser diff --git a/cmfx/user/passport/password/password.go b/cmfx/user/passport/password/password.go index 60bf2241..cde04f7b 100644 --- a/cmfx/user/passport/password/password.go +++ b/cmfx/user/passport/password/password.go @@ -10,6 +10,7 @@ import ( "time" "github.com/issue9/orm/v6" + "github.com/issue9/web" "golang.org/x/crypto/bcrypt" "github.com/issue9/cmfx/cmfx" @@ -19,6 +20,8 @@ import ( type password struct { db *orm.DB cost int + id string + desc web.LocaleStringer } func buildDB(mod *cmfx.Module, tableName string) *orm.DB { @@ -27,16 +30,21 @@ func buildDB(mod *cmfx.Module, tableName string) *orm.DB { // New 声明基于密码的验证方法 // +// id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; // cost 的值需介于 [bcrypt.MinCost,bcrypt.MaxCost] 之间,如果超出范围则会被设置为 [bcrypt.DefaultCost]。 -// 在同一个模块下需要用到多个密码验证的实例时,tableName 用于区别不同。 -func New(mod *cmfx.Module, tableName string, cost int) passport.Adapter { +// 在同一个模块下需要用到多个密码验证的实例时,tableName 用于区别不同; +func New(mod *cmfx.Module, id string, cost int, desc web.LocaleStringer) passport.Adapter { if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { cost = bcrypt.DefaultCost } - db := buildDB(mod, tableName) - return &password{db: db, cost: cost} + db := buildDB(mod, id) + return &password{db: db, cost: cost, id: id, desc: desc} } +func (p *password) ID() string { return p.id } + +func (p *password) Description() web.LocaleStringer { return p.desc } + // Add 添加账号 func (p *password) Add(uid int64, identity, pass string, now time.Time) error { if !validIdentity(identity) { @@ -92,55 +100,8 @@ func (p *password) Delete(uid int64) error { return err } -// Set 强制修改密码 -func (p *password) Set(uid int64, pass string) error { - if uid == 0 { - return passport.ErrUIDMustBeGreatThanZero() - } - - mod := &modelPassword{} - size, err := p.db.Where("uid=?", uid).Select(true, mod) - if err != nil { - return err - } - if size == 0 { - return passport.ErrUIDNotExists() - } - return p.set(mod.Identity, pass) -} - -func (p *password) set(identity, pass string) error { - pa, err := bcrypt.GenerateFromPassword([]byte(pass), p.cost) - if err == nil { - _, err = p.db.Update(&modelPassword{Identity: identity, Password: pa}) - } - return err -} - -// Change 验证并修改 -func (p *password) Change(uid int64, old, pass string) error { - if uid == 0 { - return passport.ErrUIDMustBeGreatThanZero() - } - - mod := &modelPassword{UID: uid} - size, err := p.db.Where("uid=?", uid).Select(true, mod) - if err != nil { - return err - } - if size == 0 { - return passport.ErrUIDNotExists() - } - - err = bcrypt.CompareHashAndPassword(mod.Password, []byte(old)) - switch { - case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): - return passport.ErrUnauthorized() - case err != nil: - return err - default: - return p.set(mod.Identity, pass) - } +func (p *password) Update(uid int64) error { + return nil } func (p *password) Valid(username, pass string, _ time.Time) (int64, string, error) { diff --git a/cmfx/user/passport/password/password_test.go b/cmfx/user/passport/password/password_test.go index f5fcf2d5..6ee39f5d 100644 --- a/cmfx/user/passport/password/password_test.go +++ b/cmfx/user/passport/password/password_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/issue9/assert/v4" + "github.com/issue9/web" "github.com/issue9/cmfx/cmfx/initial/test" "github.com/issue9/cmfx/cmfx/user/passport" @@ -24,10 +25,10 @@ func TestPassword(t *testing.T) { mod := suite.NewModule("test") Install(mod, "p") - p := New(mod, "p", 11) + p := New(mod, "p", 11, web.Phrase("desc")) a.NotNil(p) - adaptertest.Run(a, p) + adaptertest.RunBase(a, p) } func TestValidIdentity(t *testing.T) { @@ -37,7 +38,7 @@ func TestValidIdentity(t *testing.T) { mod := suite.NewModule("test") Install(mod, "p") - p := New(mod, "p", 11) + p := New(mod, "p", 11, web.Phrase("desc")) a.NotNil(p) a.ErrorIs(p.Add(1024, "", "1024", time.Now()), passport.ErrInvalidIdentity()) diff --git a/cmfx/user/token_test.go b/cmfx/user/token_test.go index e58fd2a4..2bf31298 100644 --- a/cmfx/user/token_test.go +++ b/cmfx/user/token_test.go @@ -30,7 +30,7 @@ func TestLoader_Login(t *testing.T) { // 添加用于测试的验证码验证 code.Install(u.Module(), "_code") - pc := code.New(u.Module(), time.Second, "_code", code.NewEmptySender()) + pc := code.New(u.Module(), time.Second, "_code", nil, code.NewEmptySender()) u.Passport().Register("code", pc, web.Phrase("code")) a.NotError(pc.Add(0, "new", "password", time.Now())) diff --git a/go.mod b/go.mod index 1f5ba67f..21da0b6c 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/issue9/logs/v7 v7.6.4 github.com/issue9/mux/v9 v9.1.0 github.com/issue9/orm/v6 v6.0.0-beta.3.0.20241018060335-bdbc5e5a6236 + github.com/issue9/rands/v3 v3.0.1 github.com/issue9/scheduled v0.21.3 github.com/issue9/sliceutil v0.17.0 github.com/issue9/upload/v3 v3.0.0-beta.1.0.20241022053811-f1d945a6d3e6 @@ -40,7 +41,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/issue9/localeutil v0.29.0 // indirect github.com/issue9/query/v3 v3.1.3 // indirect - github.com/issue9/rands/v3 v3.0.1 // indirect github.com/issue9/source v0.11.6 // indirect github.com/issue9/term/v3 v3.3.2 // indirect github.com/issue9/unique/v2 v2.1.0 // indirect From 562683cc1771effdeb9700fb6c4296ca4b11d2e3 Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 29 Oct 2024 11:18:15 +0800 Subject: [PATCH 03/11] =?UTF-8?q?refactor(cmfx):=20=E5=BA=94=E7=94=A8=20us?= =?UTF-8?q?er/passport=20=E7=9A=84=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/filters/filters.go | 8 +- cmfx/modules/admin/module.go | 31 +--- cmfx/modules/admin/route_auth.go | 159 ++++++++++-------- cmfx/user/module_test.go | 2 +- cmfx/user/passport/adapter.go | 7 + cmfx/user/passport/adaptertest/adaptertest.go | 9 + cmfx/user/passport/code/code.go | 12 ++ cmfx/user/passport/oauth/oauth.go | 12 ++ cmfx/user/passport/passport.go | 25 --- cmfx/user/passport/password/password.go | 18 +- cmfx/user/token_test.go | 4 +- 11 files changed, 162 insertions(+), 125 deletions(-) diff --git a/cmfx/filters/filters.go b/cmfx/filters/filters.go index df6766a4..205bf16d 100644 --- a/cmfx/filters/filters.go +++ b/cmfx/filters/filters.go @@ -17,7 +17,11 @@ func NilOr[T any](validator func(T) bool) filter.Builder[T] { } func Nil[T any]() filter.Builder[T] { - return filter.NewBuilder(v.V[T](v.Nil[T], locales.MustBeEmpty)) + return filter.NewBuilder(v.V(v.Nil[T], locales.MustBeEmpty)) +} + +func NotNil[T any]() filter.Builder[T] { + return filter.NewBuilder(v.V(v.Not(v.Nil[T]), locales.Required)) } // NotZero 非零值 @@ -26,7 +30,7 @@ func NotZero[T any]() filter.Builder[T] { } func Zero[T any]() filter.Builder[T] { - return filter.NewBuilder(v.V(v.Zero[T], locales.Required)) + return filter.NewBuilder(v.V(v.Zero[T], locales.MustBeEmpty)) } func Equal[T comparable](val T) filter.Builder[T] { diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index f661b5c3..9d802e13 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -17,7 +17,6 @@ import ( "github.com/issue9/webuse/v7/handlers/static" "github.com/issue9/webuse/v7/middlewares/acl/ratelimit" xrbac "github.com/issue9/webuse/v7/middlewares/acl/rbac" - "github.com/issue9/webuse/v7/middlewares/auth/token" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/initial" @@ -90,27 +89,11 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { loginRate := ratelimit.New(web.NewCache(mod.ID()+"_rate", mod.Server().Cache()), 20, time.Second, nil, nil) mod.Router().Prefix(m.URLPrefix()). - Get("/passports", m.getPassports, mod.API(func(o *openapi.Operation) { - o.Tag("admin", "auth"). - Desc(web.Phrase("get passports api"), nil). - Response("200", &respAdapters{}, nil, nil) - })). - Post("/login", m.postLogin, loginRate, initial.Unlimit(mod.Server()), mod.API(func(o *openapi.Operation) { - o.Tag("admin", "auth"). - Desc(web.Phrase("admin login api"), nil). - Body(&user.Account{}, false, nil, nil). - Response("201", &token.Response{}, nil, nil) - })). - Delete("/login", m.deleteLogin, m, mod.API(func(o *openapi.Operation) { - o.Tag("admin", "auth"). - Desc(web.Phrase("admin logout api"), nil). - ResponseRef("204", "empty", nil, nil) - })). - Put("/login", m.putToken, m, mod.API(func(o *openapi.Operation) { - o.Tag("admin", "auth"). - Desc(web.Phrase("admin refresh token api"), nil). - Response("201", &token.Response{}, nil, nil) - })) + Get("/passports", m.getPassports). + Post("/passports/{type}/code/{identity}", m.postPassportCode, loginRate, initial.Unlimit(mod.Server())). + Post("/login", m.postLogin, loginRate, initial.Unlimit(mod.Server())). + Delete("/login", m.deleteLogin, m). + Put("/login", m.putToken, m) mod.Router().Prefix(m.URLPrefix(), m). Get("/resources", m.getResources, mod.API(func(o *openapi.Operation) { @@ -224,11 +207,9 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { Delete("/passports/{type}", m.deletePassport). Post("/passports/{type}", m.postPassport). Patch("/passports/{type}", m.patchPassport). - Put("/passports/{type}", m.putPassport). Delete("/admins/{id:digit}/passports/{type}", m.deleteAdminPassport). Post("/admins/{id:digit}/passports/{type}", m.postAdminPassport). - Patch("/admins/{id:digit}/passports/{type}", m.patchAdminPassport). - Put("/admins/{id:digit}/passports/{type}", m.putAdminPassport) + Patch("/admins/{id:digit}/passports/{type}", m.patchAdminPassport) // upload up := upload.New(saver, o.Upload.Size, o.Upload.Exts...) diff --git a/cmfx/modules/admin/route_auth.go b/cmfx/modules/admin/route_auth.go index 4a41b264..a2a1c9bb 100644 --- a/cmfx/modules/admin/route_auth.go +++ b/cmfx/modules/admin/route_auth.go @@ -12,6 +12,7 @@ import ( "github.com/issue9/web/filter" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/filters" "github.com/issue9/cmfx/cmfx/locales" "github.com/issue9/cmfx/cmfx/user" "github.com/issue9/cmfx/cmfx/user/passport" @@ -60,38 +61,69 @@ func (m *Module) putToken(ctx *web.Context) web.Responser { } type respAdapters struct { - Name string `json:"name" cbor:"name" xml:"name"` + ID string `json:"id" cbor:"id" xml:"id"` Desc string `json:"desc" cbor:"desc" xml:"desc"` } // # api GET /passports 支持的登录验证方式 -// #tag admin auth +// @tag admin auth // @resp 200 * respAdapters func (m *Module) getPassports(ctx *web.Context) web.Responser { adapters := make([]*respAdapters, 0) for k, v := range m.Passport().All(ctx.LocalePrinter()) { adapters = append(adapters, &respAdapters{ - Name: k, + ID: k, Desc: v, }) } - slices.SortFunc(adapters, func(a, b *respAdapters) int { return cmp.Compare(a.Name, b.Name) }) + slices.SortFunc(adapters, func(a, b *respAdapters) int { return cmp.Compare(a.ID, b.ID) }) return web.OK(adapters) } +// # api POST /passports/{type}/code/{identity} 请求新的验证码 +// @tag admin auth +// @path id id 管理员的 ID +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 +// @resp 201 * {} +func (m *Module) postPassportCode(ctx *web.Context) web.Responser { + typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) + if resp != nil { + return resp + } + + a := m.Passport().Get(typ) + if a == nil { + return ctx.Problem(cmfx.NotFound) + } + + identity, resp := ctx.PathString("identity", cmfx.BadRequestInvalidPath) + if resp != nil { + return resp + } + + uid, err := a.UID(identity) + if err != nil { + return ctx.Error(err, "") + } + if err := a.Update(uid); err != nil { + return ctx.Error(err, "") + } + return web.Created(nil, "") +} + // # api delete /passports/{type} 取消当前用户与登录方式 type 之间的关联 -// #tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 // @resp 204 * {} func (m *Module) deletePassport(ctx *web.Context) web.Responser { return m.delAdminPassport(ctx, m.getPassport) } // # api delete /admins/{id}/passports/{type} 取消用户 id 与登录方式 type 之间的关联 -// #tag admin auth +// @tag admin auth // @path id id 管理员的 ID -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 // @resp 204 * {} func (m *Module) deleteAdminPassport(ctx *web.Context) web.Responser { return m.delAdminPassport(ctx, m.getAdminPassport) @@ -109,14 +141,42 @@ func (m *Module) delAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u return web.NoContent() } +// 验证用户当次请求的数据 +type reqValidation struct { + Type string `json:"type" cbor:"type" xml:"type"` + Identity string `json:"identity" cbor:"identity" xml:"identity"` + Password string `json:"password" cbor:"password" xml:"password"` +} + +func (v *reqValidation) Filter(c *web.FilterContext) { + c.Add(filters.NotEmpty("type", &v.Type)). + Add(filters.NotEmpty("identity", &v.Identity)). + Add(filters.NotEmpty("password", &v.Password)) +} + +func (v *reqValidation) valid(ctx *web.Context, u *user.User, p *passport.Passport) bool { + adp := p.Get(v.Type) + if adp == nil { + return false + } + + uid, _, err := adp.Valid(v.Identity, v.Password, ctx.Begin()) + return err == nil && uid == u.ID +} + type reqPassport struct { - ID string `json:"id" cbor:"id" xml:"id"` - Code string `json:"code" cbor:"code" xml:"code"` + // 用于验证的数据 + Validate *reqValidation `json:"validate" xml:"validate" cbor:"validate"` + + // 被修改的数据 + + Identity string `json:"identity" cbor:"identity" xml:"identity"` + Code string `json:"code" cbor:"code" xml:"code"` } // # api POST /passports/{type} 建立当前用户与登录方式 type 之间的关联 -// #tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 // @req * reqPassport // @resp 201 * {} func (m *Module) postPassport(ctx *web.Context) web.Responser { @@ -124,9 +184,9 @@ func (m *Module) postPassport(ctx *web.Context) web.Responser { } // # api POST /admins/{id}/passports/{type} 建立用户 id 与登录方式 type 之间的关联 -// #tag admin auth +// @tag admin auth // @path id id 管理员的 ID -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 // @req * reqPassport // @resp 201 * {} func (m *Module) postAdminPassport(ctx *web.Context) web.Responser { @@ -144,90 +204,55 @@ func (m *Module) addAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u return resp } - if err := a.Add(u.ID, data.ID, data.Code, ctx.Begin()); err != nil { + if !data.Validate.valid(ctx, u, m.Passport()) { + return ctx.Problem(cmfx.Unauthorized) + } + + if err := a.Add(u.ID, data.Identity, data.Code, ctx.Begin()); err != nil { return ctx.Error(err, "") } return web.Created(nil, "") } -type reqChangePassport struct { - New string `json:"new" cbor:"new" xml:"new"` - Old string `json:"old" cbor:"old" xml:"old"` -} - // # api patch /passports/{type} 修改当前用户的登录方式 type 的认证数据 -// #tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 -// @req * reqChangePassport +// @tag admin auth +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 +// @req * reqPassport // @resp 204 * {} func (m *Module) patchPassport(ctx *web.Context) web.Responser { return m.editAdminPassport(ctx, m.getPassport) } // # api patch /admins/{id}/passports/{type} 修改用户 id 的登录方式 type 的认证数据 -// #tag admin auth +// @tag admin auth // @path id id 管理员的 ID -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 -// @req * reqChangePassport +// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 +// @req * reqPassport // @resp 204 * {} func (m *Module) patchAdminPassport(ctx *web.Context) web.Responser { return m.editAdminPassport(ctx, m.getAdminPassport) } func (m *Module) editAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { - u, a, resp := f(ctx) + u, adp, resp := f(ctx) if resp != nil { return resp } - data := &reqChangePassport{} + data := &reqPassport{} if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { return resp } - if err := a.Change(u.ID, data.Old, data.New); err != nil { - return ctx.Error(err, "") - } - - return web.NoContent() -} - -type reqSetPassport struct { - New string `json:"new" cbor:"new" xml:"new"` -} - -// # api PUT /passports/{type} 替换当前用户登录方式 type 的认证数据 -// #tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 -// @req * reqSetPassport -// @resp 204 * {} -func (m *Module) putPassport(ctx *web.Context) web.Responser { - return m.setAdminPassport(ctx, m.getPassport) -} - -// # api PUT /admins/{id}/passports/{type} 替换用户 id 的登录方式 type 的认证数据 -// #tag admin auth -// @path id id 管理员的 ID -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 name 值。 -// @req * reqSetPassport -// @resp 204 * {} -func (m *Module) putAdminPassport(ctx *web.Context) web.Responser { - return m.setAdminPassport(ctx, m.getAdminPassport) -} - -func (m *Module) setAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { - u, a, resp := f(ctx) - if resp != nil { - return resp + if !data.Validate.valid(ctx, u, m.Passport()) { + return ctx.Problem(cmfx.Unauthorized) } - data := &reqSetPassport{} - if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { - return resp + if err := adp.Delete(u.ID); err != nil { + return ctx.Error(err, "") } - - if err := a.Set(u.ID, data.New); err != nil { + if err := adp.Add(u.ID, data.Identity, data.Code, ctx.Begin()); err != nil { return ctx.Error(err, "") } diff --git a/cmfx/user/module_test.go b/cmfx/user/module_test.go index cc3da33d..f3e8fd43 100644 --- a/cmfx/user/module_test.go +++ b/cmfx/user/module_test.go @@ -34,7 +34,7 @@ func newModule(s *test.Suite) *Module { u := Load(mod, conf) s.Assertion().NotNil(u) - u.Passport().Register("password", password.New(u.Module(), "password", 9), web.Phrase("password")) + u.Passport().Register(password.New(u.Module(), "password", 9, web.Phrase("password"))) p := u.Passport().Get("password") uid, err := u.NewUser(p, "admin", "password", time.Now()) s.Assertion().NotError(err).NotZero(uid) diff --git a/cmfx/user/passport/adapter.go b/cmfx/user/passport/adapter.go index 49820d0e..ef7db509 100644 --- a/cmfx/user/passport/adapter.go +++ b/cmfx/user/passport/adapter.go @@ -36,6 +36,13 @@ type Adapter interface { // 如果不存在,返回空值和 [ErrUIDNotExists] Identity(int64) (string, error) + // UID 获取与 identity 关联的 uid + // + // 如果不存在,返回空值和 [ErrIdentityNotExists] + // + // 如果返回 0,且不带错误信息,可能是临时验证的数据。 + UID(identity string) (int64, error) + // Delete 解绑用户 // // 如果 uid 为零值,清空所有的临时验证数据。 diff --git a/cmfx/user/passport/adaptertest/adaptertest.go b/cmfx/user/passport/adaptertest/adaptertest.go index 0b0ea8c3..1fb13c92 100644 --- a/cmfx/user/passport/adaptertest/adaptertest.go +++ b/cmfx/user/passport/adaptertest/adaptertest.go @@ -42,6 +42,15 @@ func RunBase(a *assert.Assertion, p passport.Adapter) { identity, err = p.Identity(10240) a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) + // uid + + uid, err = p.UID("1024") + a.NotError(err).Equal(uid, 1024) + uid, err = p.UID("10240") + a.Equal(err, passport.ErrIdentityNotExists()).Zero(identity) + uid, err = p.UID("2025") + a.NotError(err).Zero(identity) + // Delete a.NotError(p.Delete(1024)). diff --git a/cmfx/user/passport/code/code.go b/cmfx/user/passport/code/code.go index e5dcb747..f173f790 100644 --- a/cmfx/user/passport/code/code.go +++ b/cmfx/user/passport/code/code.go @@ -83,6 +83,18 @@ func (e *code) Identity(uid int64) (string, error) { return mod.Identity, nil } +func (e *code) UID(identity string) (int64, error) { + mod := &modelCode{} + size, err := e.db.Where("identity=?", identity).Select(true, mod) + if err != nil { + return 0, err + } + if size == 0 { + return 0, passport.ErrIdentityNotExists() + } + return mod.UID, nil +} + func (e *code) Update(uid int64) error { if uid == 0 { return passport.ErrUIDMustBeGreatThanZero() diff --git a/cmfx/user/passport/oauth/oauth.go b/cmfx/user/passport/oauth/oauth.go index 586a7b2a..973f54e7 100644 --- a/cmfx/user/passport/oauth/oauth.go +++ b/cmfx/user/passport/oauth/oauth.go @@ -119,6 +119,18 @@ func (o *OAuth[T]) Identity(uid int64) (string, error) { return mod.Identity, nil } +func (o *OAuth[T]) UID(identity string) (int64, error) { + mod := &modelOAuth{Identity: identity} + found, err := o.db.Select(mod) + if err != nil { + return 0, err + } + if !found { + return 0, passport.ErrUIDNotExists() + } + return mod.UID, nil +} + func (o *OAuth[T]) Update(_ int64) error { return nil } func (o *OAuth[T]) Add(uid int64, identity, _ string, now time.Time) error { diff --git a/cmfx/user/passport/passport.go b/cmfx/user/passport/passport.go index e4113372..4b34842e 100644 --- a/cmfx/user/passport/passport.go +++ b/cmfx/user/passport/passport.go @@ -68,31 +68,6 @@ func (p *Passport) Valid(id, identity, password string, now time.Time) (int64, s return 0, "", false } -// Set 验证并修改某个用户的验证信息 -// -// vID 用于验证的适配器 ID; -// vIdentty 用于验证的账号信息; -// vPass 用于验证的密码; -// sID 需要修改的适配器 ID; -// sIdent 需要修改的账号; -// sValue 需要修改的密码; -func (p *Passport) Set(vID, vIdent, vPass string, now time.Time, sID, sIdent, sPass string) error { - uid, _, ok := p.Valid(vID, vIdent, vPass, now) - if !ok { - return ErrUnauthorized() - } - - adp := p.Get(sID) - if adp == nil { - return ErrAdapterNotFound() - } - - if err := adp.Delete(uid); err != nil { - return err - } - return adp.Add(uid, sIdent, sPass, now) -} - // All 返回所有的适配器对象 func (p *Passport) All(printer *message.Printer) iter.Seq2[string, string] { return func(yield func(string, string) bool) { diff --git a/cmfx/user/passport/password/password.go b/cmfx/user/passport/password/password.go index cde04f7b..5ca4e6e3 100644 --- a/cmfx/user/passport/password/password.go +++ b/cmfx/user/passport/password/password.go @@ -75,6 +75,7 @@ func (p *password) Add(uid int64, identity, pass string, now time.Time) error { return passport.ErrIdentityExists() } + // NOTE: 存在 uid == 0 的临时验证数据 _, err = p.db.Update(&modelPassword{ Updated: now, UID: uid, @@ -100,9 +101,7 @@ func (p *password) Delete(uid int64) error { return err } -func (p *password) Update(uid int64) error { - return nil -} +func (p *password) Update(uid int64) error { return nil } func (p *password) Valid(username, pass string, _ time.Time) (int64, string, error) { mod := &modelPassword{Identity: username} @@ -138,6 +137,19 @@ func (p *password) Identity(uid int64) (string, error) { return mod.Identity, nil } +func (p *password) UID(identity string) (int64, error) { + mod := &modelPassword{} + size, err := p.db.Where("identity=?", identity).Select(true, mod) + if err != nil { + return 0, err + } + if size == 0 { + return 0, passport.ErrIdentityNotExists() + } + + return mod.UID, nil +} + func validIdentity(id string) bool { if id == "" { return false diff --git a/cmfx/user/token_test.go b/cmfx/user/token_test.go index 2bf31298..b94ffc90 100644 --- a/cmfx/user/token_test.go +++ b/cmfx/user/token_test.go @@ -30,8 +30,8 @@ func TestLoader_Login(t *testing.T) { // 添加用于测试的验证码验证 code.Install(u.Module(), "_code") - pc := code.New(u.Module(), time.Second, "_code", nil, code.NewEmptySender()) - u.Passport().Register("code", pc, web.Phrase("code")) + pc := code.New(u.Module(), time.Second, "code", nil, code.NewEmptySender(), web.Phrase("code")) + u.Passport().Register(pc) a.NotError(pc.Add(0, "new", "password", time.Now())) s.Module().Router().Post("/login", func(ctx *web.Context) web.Responser { From 0d972991f3f5d19683d25a126db8db495c72d68e Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 29 Oct 2024 23:13:47 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(cmfx/user/passport/otp):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20totp=20=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/initial/cmd/cmd.go | 2 +- cmfx/user/passport/code/gen.go | 23 --- cmfx/user/passport/{ => otp}/code/code.go | 2 +- .../user/passport/{ => otp}/code/code_test.go | 0 cmfx/user/passport/otp/code/gen.go | 29 +++ cmfx/user/passport/otp/code/gen_test.go | 39 +++++ cmfx/user/passport/{ => otp}/code/install.go | 0 cmfx/user/passport/{ => otp}/code/models.go | 0 .../passport/{ => otp}/code/models_test.go | 0 cmfx/user/passport/{ => otp}/code/sender.go | 0 .../passport/{ => otp}/code/sender_test.go | 0 cmfx/user/passport/otp/totp/install.go | 18 ++ cmfx/user/passport/otp/totp/install_test.go | 24 +++ cmfx/user/passport/otp/totp/models.go | 19 ++ cmfx/user/passport/otp/totp/totp.go | 165 ++++++++++++++++++ cmfx/user/passport/otp/totp/totp_test.go | 65 +++++++ cmfx/user/token_test.go | 2 +- 17 files changed, 362 insertions(+), 26 deletions(-) delete mode 100644 cmfx/user/passport/code/gen.go rename cmfx/user/passport/{ => otp}/code/code.go (98%) rename cmfx/user/passport/{ => otp}/code/code_test.go (100%) create mode 100644 cmfx/user/passport/otp/code/gen.go create mode 100644 cmfx/user/passport/otp/code/gen_test.go rename cmfx/user/passport/{ => otp}/code/install.go (100%) rename cmfx/user/passport/{ => otp}/code/models.go (100%) rename cmfx/user/passport/{ => otp}/code/models_test.go (100%) rename cmfx/user/passport/{ => otp}/code/sender.go (100%) rename cmfx/user/passport/{ => otp}/code/sender_test.go (100%) create mode 100644 cmfx/user/passport/otp/totp/install.go create mode 100644 cmfx/user/passport/otp/totp/install_test.go create mode 100644 cmfx/user/passport/otp/totp/models.go create mode 100644 cmfx/user/passport/otp/totp/totp.go create mode 100644 cmfx/user/passport/otp/totp/totp_test.go diff --git a/cmfx/initial/cmd/cmd.go b/cmfx/initial/cmd/cmd.go index 5f289df2..029fd0aa 100644 --- a/cmfx/initial/cmd/cmd.go +++ b/cmfx/initial/cmd/cmd.go @@ -26,7 +26,7 @@ import ( "github.com/issue9/cmfx/cmfx/initial" "github.com/issue9/cmfx/cmfx/modules/admin" "github.com/issue9/cmfx/cmfx/modules/system" - "github.com/issue9/cmfx/cmfx/user/passport/code" + "github.com/issue9/cmfx/cmfx/user/passport/otp/code" ) func Exec(name, version string) error { diff --git a/cmfx/user/passport/code/gen.go b/cmfx/user/passport/code/gen.go deleted file mode 100644 index 59db6d40..00000000 --- a/cmfx/user/passport/code/gen.go +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: 2024 caixw -// -// SPDX-License-Identifier: MIT - -package code - -import ( - "github.com/issue9/rands/v3" - "github.com/issue9/web" -) - -type Generator = func() string - -func NumberGenerator(s web.Server, name string) Generator { - srv := rands.New(nil, 100, 4, 5, rands.Number()) - s.Services().Add(web.Phrase("generator code for %s", name), srv) - - return func() string { - return srv.String() - } -} - -// TODO diff --git a/cmfx/user/passport/code/code.go b/cmfx/user/passport/otp/code/code.go similarity index 98% rename from cmfx/user/passport/code/code.go rename to cmfx/user/passport/otp/code/code.go index f173f790..152b4e5c 100644 --- a/cmfx/user/passport/code/code.go +++ b/cmfx/user/passport/otp/code/code.go @@ -35,7 +35,7 @@ func buildDB(mod *cmfx.Module, tableName string) *orm.DB { // tableName 用于指定验证码的表名,需要在同一个 mod 环境下是唯一的; func New(mod *cmfx.Module, expired time.Duration, id string, gen Generator, sender Sender, desc web.LocaleStringer) passport.Adapter { if gen == nil { - gen = NumberGenerator(mod.Server(), id) + gen = NumberGenerator(mod.Server(), id, 4) } return &code{ diff --git a/cmfx/user/passport/code/code_test.go b/cmfx/user/passport/otp/code/code_test.go similarity index 100% rename from cmfx/user/passport/code/code_test.go rename to cmfx/user/passport/otp/code/code_test.go diff --git a/cmfx/user/passport/otp/code/gen.go b/cmfx/user/passport/otp/code/gen.go new file mode 100644 index 00000000..430738c9 --- /dev/null +++ b/cmfx/user/passport/otp/code/gen.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package code + +import ( + "github.com/issue9/rands/v3" + "github.com/issue9/web" +) + +// Generator 生成验证码的方法签名 +type Generator = func() string + +// NumberGenerator 生成长度为 length 的数字验证码 +func NumberGenerator(s web.Server, name string, length int) Generator { + srv := rands.New(nil, 100, length, length+1, rands.Number()) + s.Services().Add(web.Phrase("generator code for %s", name), srv) + + return func() string { return srv.String() } +} + +// AlphaNumberGenerator 生成长度为 length 的验证码 +func AlphaNumberGenerator(s web.Server, name string, length int) Generator { + srv := rands.New(nil, 100, length, length+1, rands.AlphaNumber()) + s.Services().Add(web.Phrase("generator code for %s", name), srv) + + return func() string { return srv.String() } +} diff --git a/cmfx/user/passport/otp/code/gen_test.go b/cmfx/user/passport/otp/code/gen_test.go new file mode 100644 index 00000000..ca63e6c4 --- /dev/null +++ b/cmfx/user/passport/otp/code/gen_test.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package code + +import ( + "testing" + "unicode" + + "github.com/issue9/assert/v4" + + "github.com/issue9/cmfx/cmfx/initial/test" +) + +func TestGenerator(t *testing.T) { + a := assert.New(t, false) + + suite := test.NewSuite(a) + defer suite.Close() + + s := suite.Module().Server() + + f := NumberGenerator(s, "g1", 5) + a.NotNil(f) + code := f() + a.Length(code, 5).True(func() bool { + for _, r := range code { + if !unicode.IsDigit(r) { + return false + } + } + return true + }()) + + f = AlphaNumberGenerator(s, "g1", 6) + a.NotNil(f) + a.Length(f(), 6) +} diff --git a/cmfx/user/passport/code/install.go b/cmfx/user/passport/otp/code/install.go similarity index 100% rename from cmfx/user/passport/code/install.go rename to cmfx/user/passport/otp/code/install.go diff --git a/cmfx/user/passport/code/models.go b/cmfx/user/passport/otp/code/models.go similarity index 100% rename from cmfx/user/passport/code/models.go rename to cmfx/user/passport/otp/code/models.go diff --git a/cmfx/user/passport/code/models_test.go b/cmfx/user/passport/otp/code/models_test.go similarity index 100% rename from cmfx/user/passport/code/models_test.go rename to cmfx/user/passport/otp/code/models_test.go diff --git a/cmfx/user/passport/code/sender.go b/cmfx/user/passport/otp/code/sender.go similarity index 100% rename from cmfx/user/passport/code/sender.go rename to cmfx/user/passport/otp/code/sender.go diff --git a/cmfx/user/passport/code/sender_test.go b/cmfx/user/passport/otp/code/sender_test.go similarity index 100% rename from cmfx/user/passport/code/sender_test.go rename to cmfx/user/passport/otp/code/sender_test.go diff --git a/cmfx/user/passport/otp/totp/install.go b/cmfx/user/passport/otp/totp/install.go new file mode 100644 index 00000000..4eb76970 --- /dev/null +++ b/cmfx/user/passport/otp/totp/install.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package hotp + +import ( + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" +) + +func Install(mod *cmfx.Module, tableName string) { + db := buildDB(mod, tableName) + if err := db.Create(&modelTOTP{}); err != nil { + panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) + } +} diff --git a/cmfx/user/passport/otp/totp/install_test.go b/cmfx/user/passport/otp/totp/install_test.go new file mode 100644 index 00000000..415503b4 --- /dev/null +++ b/cmfx/user/passport/otp/totp/install_test.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package hotp + +import ( + "testing" + + "github.com/issue9/assert/v4" + + "github.com/issue9/cmfx/cmfx/initial/test" +) + +func TestInstall(t *testing.T) { + a := assert.New(t, false) + suite := test.NewSuite(a) + defer suite.Close() + + mod := suite.NewModule("test") + Install(mod, "totp") + + suite.TableExists(mod.ID() + "_auth_totp") +} diff --git a/cmfx/user/passport/otp/totp/models.go b/cmfx/user/passport/otp/totp/models.go new file mode 100644 index 00000000..b65f1a0f --- /dev/null +++ b/cmfx/user/passport/otp/totp/models.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +package hotp + +import "time" + +type modelTOTP struct { + ID int64 `orm:"name(id);ai"` + Created time.Time `orm:"name(created)"` + Updated time.Time `orm:"name(updated)"` + + UID int64 `orm:"name(uid);default(0)"` + Identity string `orm:"name(identity);len(32);unique(identity)"` + Secret []byte `orm:"name(secret);len(160)"` +} + +func (p *modelTOTP) TableName() string { return `` } diff --git a/cmfx/user/passport/otp/totp/totp.go b/cmfx/user/passport/otp/totp/totp.go new file mode 100644 index 00000000..477ea058 --- /dev/null +++ b/cmfx/user/passport/otp/totp/totp.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +// Package hotp 提供基于 [TOTP] 的 [passport.Adapter] 实现 +// +// [TOTP]: https://datatracker.ietf.org/doc/html/rfc6238 +package hotp + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + "math" + "strconv" + "time" + + "github.com/issue9/orm/v6" + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/user/passport" +) + +type totp struct { + mod *cmfx.Module + db *orm.DB + id string + desc web.LocaleStringer +} + +// New 声明基于 [TOTP] 的 [passport.Adapter] 实现 +// +// [TOTP]: https://datatracker.ietf.org/doc/html/rfc6238 +func New(mod *cmfx.Module, id string, desc web.LocaleStringer) passport.Adapter { + db := buildDB(mod, id) + return &totp{ + mod: mod, + db: db, + id: id, + desc: desc, + } +} + +func (p *totp) ID() string { return p.id } + +func (p *totp) Description() web.LocaleStringer { return p.desc } + +// Add 添加账号 +func (p *totp) Add(uid int64, identity, _ string, now time.Time) error { + n, err := p.db.Where("uid=?", uid).Count(&modelTOTP{}) + if err != nil { + return err + } + if uid > 0 && n > 0 { + return passport.ErrUIDExists() + } + + mod := &modelTOTP{Identity: identity} + found, err := p.db.Select(mod) + if err != nil { + return err + } + + secret := []byte(p.mod.Server().UniqueID()) + + if found { + if mod.UID > 0 { // 存在同一个值的 + return passport.ErrIdentityExists() + } + + // NOTE: 存在 uid == 0 的临时验证数据 + _, err = p.db.Update(&modelTOTP{ + Updated: now, + UID: uid, + Identity: identity, + Secret: secret, + }) + } else { + _, err = p.db.Insert(&modelTOTP{ + Created: now, + Updated: now, + UID: uid, + Identity: identity, + Secret: secret, + }) + } + + return err +} + +func (p *totp) Delete(uid int64) error { + _, err := p.db.Where("uid=?", uid).Delete(&modelTOTP{}) + return err +} + +func (p *totp) Update(uid int64) error { return nil } + +func (p *totp) Valid(username, pass string, now time.Time) (int64, string, error) { + mod := &modelTOTP{Identity: username} + found, err := p.db.Select(mod) + if err != nil { + return 0, "", err + } + if !found { + return 0, "", passport.ErrUnauthorized() + } + + // 将时间戳转换为字节数组 + msg := make([]byte, 8) + binary.BigEndian.PutUint64(msg, uint64(now.Unix()/30)) + h := hmac.New(sha1.New, mod.Secret) + h.Write(msg) + hmacHash := h.Sum(nil) + + // 获取偏移量 + offset := hmacHash[len(hmacHash)-1] & 0xf + binaryCode := ((int32(hmacHash[offset]) & 0x7f) << 24) | + ((int32(hmacHash[offset+1] & 0xff)) << 16) | + ((int32(hmacHash[offset+2] & 0xff)) << 8) | + (int32(hmacHash[offset+3]) & 0xff) + + const digitLen = 6 + + otp := int(binaryCode) % int(math.Pow10(digitLen)) + result := strconv.Itoa(otp) + for len(result) < digitLen { // 补齐前导的 0 + result = "0" + result + } + + if result == pass { + return mod.UID, mod.Identity, nil + } + return 0, "", passport.ErrUnauthorized() +} + +func (p *totp) Identity(uid int64) (string, error) { + mod := &modelTOTP{} + size, err := p.db.Where("uid=?", uid).Select(true, mod) + if err != nil { + return "", err + } + if size == 0 { + return "", passport.ErrUIDNotExists() + } + + return mod.Identity, nil +} + +func (p *totp) UID(identity string) (int64, error) { + mod := &modelTOTP{} + size, err := p.db.Where("identity=?", identity).Select(true, mod) + if err != nil { + return 0, err + } + if size == 0 { + return 0, passport.ErrIdentityNotExists() + } + + return mod.UID, nil +} + +func buildDB(mod *cmfx.Module, tableName string) *orm.DB { + return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) +} diff --git a/cmfx/user/passport/otp/totp/totp_test.go b/cmfx/user/passport/otp/totp/totp_test.go new file mode 100644 index 00000000..9d7b2efc --- /dev/null +++ b/cmfx/user/passport/otp/totp/totp_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package hotp + +import ( + "testing" + "time" + + "github.com/issue9/assert/v4" + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx/initial/test" + "github.com/issue9/cmfx/cmfx/user/passport" +) + +func TestTOTP(t *testing.T) { + a := assert.New(t, false) + + suite := test.NewSuite(a) + defer suite.Close() + mod := suite.NewModule("test") + Install(mod, "totp") + p := New(mod, "totp", web.Phrase("totp")) + + // Add + + a.NotError(p.Add(1024, "1024", "1024", time.Now())) + a.ErrorIs(p.Add(1024, "1024", "1024", time.Now()), passport.ErrUIDExists()) + a.ErrorIs(p.Add(1000, "1024", "1024", time.Now()), passport.ErrIdentityExists()) + + a.NotError(p.Add(0, "2025", "2025", time.Now())) + a.NotError(p.Add(0, "2026", "2026", time.Now())) + a.ErrorIs(p.Add(111, "1024", "1024", time.Now()), passport.ErrIdentityExists()) // 1024 已经有 uid + a.NotError(p.Add(2025, "2025", "2025", time.Now())) // 将 "2025" 关联 uid + + // Identity + + identity, err := p.Identity(1024) + a.NotError(err).Equal(identity, "1024") + identity, err = p.Identity(10240) + a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) + + // uid + + uid, err := p.UID("1024") + a.NotError(err).Equal(uid, 1024) + uid, err = p.UID("10240") + a.Equal(err, passport.ErrIdentityNotExists()).Zero(identity) + uid, err = p.UID("2025") + a.NotError(err).Zero(identity) + + // Delete + + a.NotError(p.Delete(1024)). + NotError(p.Delete(1024)) // 多次删除 + identity, err = p.Identity(1024) + a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) + + // Update + + a.NotError(p.Update(1025)) + a.NotError(p.Update(1024)) +} diff --git a/cmfx/user/token_test.go b/cmfx/user/token_test.go index b94ffc90..1cf50dbd 100644 --- a/cmfx/user/token_test.go +++ b/cmfx/user/token_test.go @@ -20,7 +20,7 @@ import ( "github.com/issue9/webuse/v7/middlewares/auth/token" "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport/code" + "github.com/issue9/cmfx/cmfx/user/passport/otp/code" ) func TestLoader_Login(t *testing.T) { From ef85a36028ad9626726376db06e48b9aad5da215 Mon Sep 17 00:00:00 2001 From: caixw Date: Fri, 1 Nov 2024 13:17:59 +0800 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=E6=96=B0=E7=9A=84=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=8A=9F=E8=83=BD=E5=BA=94=E7=94=A8=E5=88=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/app/context/context.tsx | 4 +- admin/src/app/context/user.ts | 14 +- admin/src/app/index.ts | 2 +- admin/src/components/table/style.css | 9 +- admin/src/core/api/api.ts | 4 +- admin/src/core/api/types.ts | 1 + admin/src/messages/cmn-Hans.ts | 12 +- admin/src/messages/en.ts | 12 +- admin/src/pages/admins/admins.tsx | 54 +++--- admin/src/pages/admins/edit.tsx | 105 +++++++++-- admin/src/pages/admins/new.tsx | 27 ++- admin/src/pages/admins/selector.tsx | 20 +- admin/src/pages/admins/types.ts | 60 ------ admin/src/pages/current/login.tsx | 29 ++- admin/src/pages/current/logout.tsx | 5 +- admin/src/pages/current/profile.tsx | 174 ++++++++++++------ admin/src/pages/current/style.css | 10 +- admin/tailwind.preset.ts | 1 + cmfx/cmfx.go | 3 +- cmfx/initial/cmd/cmd.go | 10 +- cmfx/initial/initial.go | 1 + cmfx/locales/und.yaml | 36 ++-- cmfx/locales/zh.yaml | 36 ++-- cmfx/modules/admin/module.go | 5 +- cmfx/modules/admin/route_admins.go | 18 +- cmfx/modules/admin/route_auth.go | 194 +++++--------------- cmfx/modules/admin/route_current.go | 35 +++- cmfx/user/passport/otp/totp/install.go | 2 +- cmfx/user/passport/otp/totp/install_test.go | 2 +- cmfx/user/passport/otp/totp/models.go | 2 +- cmfx/user/passport/otp/totp/totp.go | 4 +- cmfx/user/passport/otp/totp/totp_test.go | 2 +- cmfx/user/token.go | 18 +- cmfx/user/token_test.go | 47 ++--- 34 files changed, 482 insertions(+), 476 deletions(-) delete mode 100644 admin/src/pages/admins/types.ts diff --git a/admin/src/app/context/context.tsx b/admin/src/app/context/context.tsx index 9c94f1e8..e4b8c885 100644 --- a/admin/src/app/context/context.tsx +++ b/admin/src/app/context/context.tsx @@ -182,8 +182,8 @@ export function buildContext(opt: Required, f: API) { * @param account 账号密码信息 * @returns true 表示登录成功,其它情况表示错误信息 */ - async login(account: Account,type: string = 'password') { - const ret = await f.login(account, type); + async login(account: Account) { + const ret = await f.login(account); if (ret === true) { uid = account.username; sessionStorage.setItem(currentKey, uid); diff --git a/admin/src/app/context/user.ts b/admin/src/app/context/user.ts index 00a1cf30..9a3f63cd 100644 --- a/admin/src/app/context/user.ts +++ b/admin/src/app/context/user.ts @@ -7,8 +7,16 @@ */ export interface User { id?: number; - sex?: 'unknown' | 'male' | 'female'; - name?: string; - nickname?: string; + sex: 'male' | 'female' | 'unknown'; + state: 'normal' | 'locked' | 'deleted'; + name: string; + nickname: string; avatar?: string; + roles?: Array; + passports?: Array; +} + +interface Passport { + id: string; + username: string; } diff --git a/admin/src/app/index.ts b/admin/src/app/index.ts index 7353cf18..7db9dfc5 100644 --- a/admin/src/app/index.ts +++ b/admin/src/app/index.ts @@ -6,5 +6,5 @@ export { create as createApp } from './app'; export type { MenuItem, Options, Route, Routes } from './options'; export { useApp } from './context'; -export type { AppContext } from './context'; +export type { AppContext, User } from './context'; diff --git a/admin/src/components/table/style.css b/admin/src/components/table/style.css index ca31b287..49d00db6 100644 --- a/admin/src/components/table/style.css +++ b/admin/src/components/table/style.css @@ -15,13 +15,16 @@ @apply text-center w-full text-xl py-10; } + tr { + @apply border-b border-palette-fg-low; + } + th, td { - @apply px-2 py-1 text-left border-b border-palette-fg-low; + @apply px-2 py-1 text-left; } - tbody tr:last-of-type th, - tbody tr:last-of-type td { + tbody tr:last-of-type { @apply border-0; } diff --git a/admin/src/core/api/api.ts b/admin/src/core/api/api.ts index b1777cf4..fe2a3613 100644 --- a/admin/src/core/api/api.ts +++ b/admin/src/core/api/api.ts @@ -201,8 +201,8 @@ export class API { * * @returns 如果返回 true,表示操作成功,否则表示错误信息。 */ - async login(account: Account, type: string): Promise|undefined|true> { - const token = await this.post(this.#loginPath + '?type='+type, account, false); + async login(account: Account): Promise|undefined|true> { + const token = await this.post(this.#loginPath, account, false); if (token.ok) { this.#token = writeToken(token.body!); await this.clearCache(); diff --git a/admin/src/core/api/types.ts b/admin/src/core/api/types.ts index a397cfa2..a7fcaad3 100644 --- a/admin/src/core/api/types.ts +++ b/admin/src/core/api/types.ts @@ -72,6 +72,7 @@ export type Return = { * 登录接口的需要用户提供的对象 */ export interface Account { + type: string; username: string; password: string; } diff --git a/admin/src/messages/cmn-Hans.ts b/admin/src/messages/cmn-Hans.ts index 36dfccd8..5d7f7e45 100644 --- a/admin/src/messages/cmn-Hans.ts +++ b/admin/src/messages/cmn-Hans.ts @@ -57,10 +57,10 @@ const messages: Messages = { profile: '个人信息', name: '姓名', nickname: '昵称', - oldPassword: '旧密码', - newPassword: '新密码', - confirmPassword: '确认新密码', pickAvatar: '选择头像', + requestCode: '发送验证码', + delete: '删除', + create: '创建账号', }, system: { apis: 'API', @@ -116,12 +116,12 @@ const messages: Messages = { adminsManager: '管理员', name: '姓名', nickname: '昵称', - resetPassword: '重置密码', addSuccessful: '添加成功', - areYouSureResetPassword: '确定要重置用户的登录密码?', - successfullyResetPassword: '重置密码成功', lockUser: '锁定该用户', unlockUser: '解锁该用户', + + passport: '登录方式', + passportTtype: '类型', }, roles: { roles: '角色', diff --git a/admin/src/messages/en.ts b/admin/src/messages/en.ts index 85f9ebec..acbb049b 100644 --- a/admin/src/messages/en.ts +++ b/admin/src/messages/en.ts @@ -55,10 +55,10 @@ const messages = { profile: 'profle', name: 'name', nickname: 'nickname', - oldPassword: 'old password', - newPassword: 'new password', - confirmPassword: 'confirm password', pickAvatar: 'pick avatar', + requestCode: 'request code', + delete: 'delete', + create: 'create', }, system: { apis: 'API', @@ -114,12 +114,12 @@ const messages = { adminsManager: 'admin manager', name: 'name', nickname: 'nick name', - resetPassword: 'Reset password', addSuccessful: 'Add user successful', - areYouSureResetPassword: 'Are you sure reset user password', - successfullyResetPassword: 'Successfully reset password', lockUser: 'Lock user', unlockUser: 'unlock user', + + passport: 'passport', + passportTtype: 'type', }, roles: { roles: 'roles', diff --git a/admin/src/pages/admins/admins.tsx b/admin/src/pages/admins/admins.tsx index fe882527..60e5ffd2 100644 --- a/admin/src/pages/admins/admins.tsx +++ b/admin/src/pages/admins/admins.tsx @@ -5,13 +5,15 @@ import { JSX, Show } from 'solid-js'; import { useApp } from '@/app'; -import { - Button, ConfirmButton, LinkButton, Page, - RemoteTable, RemoteTableRef, TextField, translateEnum -} from '@/components'; -import { SexSelector, StateSelector } from './selector'; -import type { Admin, Query, Sex, State } from './types'; -import { sexesMap, statesMap } from './types'; +import { Button, LinkButton, Page, RemoteTable, RemoteTableRef, TextField, translateEnum } from '@/components'; +import { Query as QueryBase } from '@/core'; +import { Sex, sexesMap, SexSelector, State, StateSelector, statesMap } from './selector'; + +export interface Query extends QueryBase { + text?: string; + state?: Array; + sex?: Array; +} interface Props { /** @@ -32,7 +34,7 @@ export default function(props: Props): JSX.Element { const q: Q = { text: '', page: 1, - state: ['normal'], + state: ['normal','locked'], sex: ['male', 'female', 'unknown'] }; @@ -66,11 +68,13 @@ export default function(props: Props): JSX.Element { { id: 'actions', label: ctx.locale().t('_i.page.actions'), isUnexported: true, renderContent: ((_, __, obj?: Admin) => { return
- edit + + edit + - + - { - const r = await ctx.api.delete(`/admins/${obj!['id']}/password`); - if (!r.ok) { - ctx.outputProblem(r.body); - return; - } - ctx.notify(ctx.locale().t('_i.page.admin.successfullyResetPassword'), undefined, 'success'); - }}>lock_reset - - {ref.DeleteAction(obj!.id!)} + + {ref.DeleteAction(obj!.id!)} +
; }) }, ]} /> ; } + +export interface Admin { + id?: number; + no?: string; + sex: Sex; + name: string; + nickname: string; + avatar?: string; + created?: string; + state: State; +} diff --git a/admin/src/pages/admins/edit.tsx b/admin/src/pages/admins/edit.tsx index 8fabcbee..395013d1 100644 --- a/admin/src/pages/admins/edit.tsx +++ b/admin/src/pages/admins/edit.tsx @@ -2,22 +2,103 @@ // // SPDX-License-Identifier: MIT -import { useNavigate, useParams } from '@solidjs/router'; -import { JSX } from 'solid-js'; +import { useParams } from '@solidjs/router'; +import { createSignal, For, JSX, onMount } from 'solid-js'; -import { useApp } from '@/app'; -import { Button, Page } from '@/components'; +import { useApp, User } from '@/app'; +import { Button, Divider, Form, FormAccessor, Icon, Page, TextField } from '@/components'; +import { roles } from '@/pages/roles'; +import { Sex, SexSelector } from './selector'; export default function(): JSX.Element { const ctx = useApp(); const ps = useParams<{id: string}>(); - const nav = useNavigate(); - - return -
-
{ps.id}
- - -
+ + const [passports, setPassports] = createSignal>([]); + + const form = new FormAccessor(zeroAdmin(), ctx, async(obj)=>{ + return await ctx.api.patch(`/admins/${ps.id}`, obj); + }); + const formPassports = form.accessor('passports'); + + + onMount(async () => { + const r1 = await ctx.api.get(`/admins/${ps.id}`); + if (r1.ok) { + form.setPreset(r1.body!); + form.setObject(r1.body!); + } else { + ctx.outputProblem(r1.body); + } + + const r2 = await ctx.api.get>('/passports'); + if (!r2.ok) { + ctx.outputProblem(r2.body); + return; + } + setPassports(r2.body!); + }); + + return +
+ ('name')} label={ctx.locale().t('_i.page.admin.name')} /> + ('nickname')} label={ctx.locale().t('_i.page.admin.nickname')} /> + >('roles')} label={ctx.locale().t('_i.page.roles.roles')} /> + ('sex')} label={ctx.locale().t('_i.page.sex')} /> +
+ + +
+ + + {ctx.locale().t('_i.page.admin.passport')} + +
+ + + + + + + + + + {(item) => { + const username = formPassports.getValue()!.find((v) => v.id == item.id)?.username; + return + + + ; + }} + + +
{ctx.locale().t('_i.page.admin.passportTtype')}{ctx.locale().t('_i.page.current.username')}
+ {item.id} + + {username}
+
; } + +interface Admin { + sex: Sex; + name: string; + nickname: string; + roles: Array; + passports?: User['passports']; +} + +function zeroAdmin(): Admin { + return { + sex: 'unknown', + name: '', + nickname: '', + roles: [], + passports: [], + }; +} + +export interface Passport { + id: string; + desc: string; +} diff --git a/admin/src/pages/admins/new.tsx b/admin/src/pages/admins/new.tsx index 3489000f..bd9ab244 100644 --- a/admin/src/pages/admins/new.tsx +++ b/admin/src/pages/admins/new.tsx @@ -4,22 +4,17 @@ import { useNavigate } from '@solidjs/router'; import { JSX } from 'solid-js'; -import { unwrap } from 'solid-js/store'; import { useApp } from '@/app'; import { Button, Form, FormAccessor, Page, Password, TextField } from '@/components'; import { roles } from '@/pages/roles'; -import { SexSelector } from './selector'; -import { Admin, Sex, zeroAdmin } from './types'; +import { Sex, SexSelector } from './selector'; export default function(): JSX.Element { const ctx = useApp(); const nav = useNavigate(); const form = new FormAccessor(zeroAdmin(), ctx, async (obj) => { - const o = unwrap(obj); - delete o.id; - delete o.created; return await ctx.api.post('/admins', obj); }, () => { ctx.notify(ctx.locale().t('_i.page.admin.addSuccessful'), undefined, 'success'); @@ -41,3 +36,23 @@ export default function(): JSX.Element {
; } + +interface Admin { + sex: Sex; + name: string; + nickname: string; + roles: Array; + username: string; + password: string; +} + +export function zeroAdmin(): Admin { + return { + sex: 'unknown', + name: '', + nickname: '', + roles: [], + username: '', + password: '', + }; +} diff --git a/admin/src/pages/admins/selector.tsx b/admin/src/pages/admins/selector.tsx index 54dde184..790d2d4a 100644 --- a/admin/src/pages/admins/selector.tsx +++ b/admin/src/pages/admins/selector.tsx @@ -4,9 +4,25 @@ import { JSX } from 'solid-js'; -import { useApp } from '@/app'; +import { useApp, User } from '@/app'; import { buildEnumsOptions, Choice, ChoiceProps } from '@/components'; -import { Sex, sexesMap, State, statesMap } from './types'; +import { MessagesKey } from '@/messages'; + +export type Sex = User['sex']; + +export type State = User['state']; + +export const sexesMap: Array<[Sex, MessagesKey]> = [ + ['male', '_i.page.sexes.male'], + ['female', '_i.page.sexes.female'], + ['unknown', '_i.page.sexes.unknown'], +] as const; + +export const statesMap: Array<[State, MessagesKey]> = [ + ['normal', '_i.page.states.normal'], + ['locked', '_i.page.states.locked'], + ['deleted', '_i.page.states.deleted'], +]; export type SexSelectorProps = Omit,'options'>; diff --git a/admin/src/pages/admins/types.ts b/admin/src/pages/admins/types.ts deleted file mode 100644 index 9bb47158..00000000 --- a/admin/src/pages/admins/types.ts +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: 2024 caixw -// -// SPDX-License-Identifier: MIT - -import { Query as QueryBase } from '@/core'; -import { MessagesKey } from '@/messages'; - -export type Sex = 'male' | 'female' | 'unknown'; - -export type State = 'normal' | 'locked' | 'deleted'; - -export const sexesMap: Array<[Sex, MessagesKey]> = [ - ['male', '_i.page.sexes.male'], - ['female', '_i.page.sexes.female'], - ['unknown', '_i.page.sexes.unknown'], -] as const; - -export const statesMap: Array<[State, MessagesKey]> = [ - ['normal', '_i.page.states.normal'], - ['locked', '_i.page.states.locked'], - ['deleted', '_i.page.states.deleted'], -]; - -export interface Query extends QueryBase { - text?: string; - state?: Array; - sex?: Array; -} - -export interface Admin { - id?: number; - no: string; - sex: Sex; - name: string; - nickname: string; - avatar?: string; - created?: string; - state: State; - roles: Array; - - username?: string; - password?: string; -} - -export function zeroAdmin(): Admin { - return { - id: 0, - no: '', - sex: 'unknown', - name: '', - nickname: '', - avatar: '', - created: '', - state: 'normal', - roles: [], - - username: '', - password: '', - }; -} diff --git a/admin/src/pages/current/login.tsx b/admin/src/pages/current/login.tsx index 4bf3a823..837f6797 100644 --- a/admin/src/pages/current/login.tsx +++ b/admin/src/pages/current/login.tsx @@ -6,8 +6,9 @@ import { Navigate, useNavigate } from '@solidjs/router'; import { createSignal, For, JSX, Match, onMount, Show, Switch } from 'solid-js'; import { useApp, useOptions } from '@/app/context'; -import { buildEnumsOptions, Button, Choice, FieldAccessor, Icon, ObjectAccessor, Page, Password, TextField } from '@/components'; +import { buildEnumsOptions, Button, Choice, Icon, ObjectAccessor, Page, Password, TextField } from '@/components'; import { Account } from '@/core'; +import { Passport } from '@/pages/admins/edit'; interface Props { /** @@ -44,8 +45,9 @@ export function Login(props: Props): JSX.Element { const opt = useOptions(); const nav = useNavigate(); + ctx.api.cache('/passports'); + const [passports, setPassports] = createSignal>([]); - const passport = FieldAccessor('passport', 'password'); onMount(async () => { const r = await ctx.api.get>('/passports'); @@ -53,14 +55,14 @@ export function Login(props: Props): JSX.Element { ctx.outputProblem(r.body); return; } - setPassports(r.body!.map((v)=>[v.name,v.desc])); + setPassports(r.body!.map((v)=>[v.id,v.desc])); }); - const f = new ObjectAccessor({ username: '', password: '' }); + const account = new ObjectAccessor({ type: 'passwords', username: '', password: '' }); return ; } - -interface Passport { - name: string; - desc: string; -} diff --git a/admin/src/pages/current/logout.tsx b/admin/src/pages/current/logout.tsx index 6e543456..fd19ff40 100644 --- a/admin/src/pages/current/logout.tsx +++ b/admin/src/pages/current/logout.tsx @@ -6,6 +6,7 @@ import { useNavigate } from '@solidjs/router'; import { JSX, onMount } from 'solid-js'; import { useApp, useOptions } from '@/app/context'; +import { Page } from '@/components'; export default function(): JSX.Element { const ctx = useApp(); @@ -18,5 +19,7 @@ export default function(): JSX.Element { }); // 在网络不通时,ctx.logout 可能会非常耗时,所以此处展示一个简单的提示页面。 - return
{ ctx.locale().t('_i.page.current.loggingOut') }
; + return + { ctx.locale().t('_i.page.current.loggingOut') } + } diff --git a/admin/src/pages/current/profile.tsx b/admin/src/pages/current/profile.tsx index 010d586d..d11c415b 100644 --- a/admin/src/pages/current/profile.tsx +++ b/admin/src/pages/current/profile.tsx @@ -2,21 +2,23 @@ // // SPDX-License-Identifier: MIT -import { createEffect, createSignal, JSX, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, JSX, onMount, Show } from 'solid-js'; import { useApp, useOptions, User } from '@/app/context'; import { - buildEnumsOptions, Button, Choice, Divider, file2Base64, Form, - FormAccessor, Page, Password, TextField, Upload, UploadRef + buildEnumsOptions, Button, Choice, ConfirmButton, Dialog, DialogRef, Divider, + file2Base64, Form, FormAccessor, Icon, ObjectAccessor, Page, TextField, Upload, UploadRef } from '@/components'; -import { Sex, sexesMap, zeroAdmin } from '@/pages/admins/types'; +import { Passport } from '@/pages/admins/edit'; +import { Sex, sexesMap } from '@/pages/admins/selector'; export default function(): JSX.Element { const opt = useOptions(); const ctx = useApp(); let uploadRef: UploadRef; + let dialogRef: DialogRef; - const infoAccess = new FormAccessor(zeroAdmin(), ctx, (obj)=>{ + const infoAccess = new FormAccessor({sex: 'unknown',state: 'normal',name: '',nickname: '', passports: []}, ctx, (obj)=>{ return ctx.api.patch(opt.api.info, obj); }, async () => { await ctx.refetchUser(); @@ -32,6 +34,9 @@ export default function(): JSX.Element { const nameA = infoAccess.accessor('name'); const nicknameA = infoAccess.accessor('nickname'); const sexA = infoAccess.accessor('sex'); + const passportA = infoAccess.accessor('passports'); + + const [passports, setPassports] = createSignal>([]); const [avatar, setAvatar] = createSignal(''); let originAvatar = ''; // 原始的头像内容,在取消上传头像时,可以从此值恢复。 @@ -40,11 +45,12 @@ export default function(): JSX.Element { const u = ctx.user(); if (!u) { return; } - infoAccess.setPreset({ name: u.name, nickname: u.nickname, sex: u.sex }); + infoAccess.setPreset(u); nameA.setValue(u.name!); nicknameA.setValue(u.nickname!); sexA.setValue(u.sex!); + passportA.setValue(u.passports); setAvatar(u.avatar!); originAvatar = u.avatar!; @@ -55,8 +61,20 @@ export default function(): JSX.Element { setAvatar(await file2Base64(uploadRef.files()[0])); } }); + + onMount(async () => { + const r = await ctx.api.get>('/passports'); + if (!r.ok) { + ctx.outputProblem(r.body); + return; + } + setPassports(r.body!); + }); + + const account = new ObjectAccessor({ username: '', password: '' }); + const [current, setCurrent] = createSignal(''); - return + return uploadRef = el} fieldName='files' action='/upload' />
avatar @@ -83,10 +101,10 @@ export default function(): JSX.Element { await ctx.refetchUser(); }}>{ctx.locale().t('_i.page.save')} - + }}>{ctx.locale().t('_i.cancel')}
@@ -94,59 +112,93 @@ export default function(): JSX.Element { -
-
- - - - -
- - -
- +
+ + + -
- - -
+
+ + +
+ + + {ctx.locale().t('_i.page.admin.passport')} + +
+ + + + + + + + + + + {(item) => { + const username = createMemo(() => passportA.getValue()!.find((v) => v.id == item.id)?.username); + return + + + + + ; + }} + + +
{ctx.locale().t('_i.page.admin.passportTtype')}{ctx.locale().t('_i.page.current.username')}{ctx.locale().t('_i.page.actions')}
+ {item.id} + + {username()} + + + + { + const r = await ctx.api.delete(`/passports/${item.id}`); + if (!r.ok) { + await ctx.outputProblem(r.body); + return; + } + ctx.refetchUser(); + }}>delete + + + + + +
+
+ + dialogRef = el} header={ctx.locale().t('_i.page.current.create')} + actions={dialogRef!.DefaultActions(async ()=>{ + const r = await ctx.api.post(`/passports/${current()}`, account.object()); + if (!r.ok) { + await ctx.outputProblem(r.body); + return undefined; + } + + ctx.refetchUser(); + return undefined; + })}> +
+ ('username')} /> + ('password')} /> + +
; } -interface ProfilePassword { - old: string; - new: string; - confirm: string; -} - -function Pass(): JSX.Element { - const ctx = useApp(); - - const passAccess = new FormAccessor({ old: '', new: '', confirm: '' }, ctx, (obj)=>{ - return ctx.api.put('/password', obj); - }, undefined, (obj)=>{ - if (obj.old === '') { - return new Map([['old', ctx.locale().t('_i.error.canNotBeEmpty')]]); - } - if (obj.new === '') { - return new Map([['new', ctx.locale().t('_i.error.canNotBeEmpty')]]); - } - - if (obj.old === obj.new) { - return new Map([['new', ctx.locale().t('_i.error.oldNewPasswordCanNotBeEqual')]]); - } - if (obj.new !== obj.confirm) { - return new Map([['confirm', ctx.locale().t('_i.error.newConfirmPasswordMustBeEqual')]]); - } - }); - - return
- - - - -
- -
- ; -} +interface Account { + username: string; + password: string; +} \ No newline at end of file diff --git a/admin/src/pages/current/style.css b/admin/src/pages/current/style.css index 1fb796ad..89084de7 100644 --- a/admin/src/pages/current/style.css +++ b/admin/src/pages/current/style.css @@ -48,16 +48,18 @@ } .p--profile { - .content { - @apply flex justify-between flex-col sm:flex-row box-border sm:gap-8 gap-10; - } + @apply max-w-xs; .form { - @apply flex flex-col sm:w-[50%] w-full; + @apply flex flex-col w-full; .actions { @apply w-full flex justify-end gap-5; } } } + + .p--logout { + @apply items-center justify-center; + } } diff --git a/admin/tailwind.preset.ts b/admin/tailwind.preset.ts index 89f5969f..fea6dd87 100644 --- a/admin/tailwind.preset.ts +++ b/admin/tailwind.preset.ts @@ -74,6 +74,7 @@ const config: PresetsConfig = { minWidth: breakpoints, maxWidth: breakpoints, + width: breakpoints, transitionDuration: { 'preset':'var(--transition-duration)', diff --git a/cmfx/cmfx.go b/cmfx/cmfx.go index 47e6284b..24c13166 100644 --- a/cmfx/cmfx.go +++ b/cmfx/cmfx.go @@ -37,7 +37,8 @@ const ( BadRequestInvalidHeader = "40003" BadRequestInvalidBody = "40004" BadRequestBodyTooLarger = "40005" - BadRequestBodyNotAllowed = "40006" + BadRequestBodyNotAllowed = "40006" // 提交内容的类型不允许,比如不允许的上传类型等 + BadRequestLastPassport = "40007" // 最后一个验证方式,不允许删除。 ) // 401 diff --git a/cmfx/initial/cmd/cmd.go b/cmfx/initial/cmd/cmd.go index 029fd0aa..7b5f7964 100644 --- a/cmfx/initial/cmd/cmd.go +++ b/cmfx/initial/cmd/cmd.go @@ -7,9 +7,7 @@ package cmd import ( "flag" - "net/smtp" "path/filepath" - "time" "github.com/issue9/upload/v3" "github.com/issue9/web" @@ -26,7 +24,7 @@ import ( "github.com/issue9/cmfx/cmfx/initial" "github.com/issue9/cmfx/cmfx/modules/admin" "github.com/issue9/cmfx/cmfx/modules/system" - "github.com/issue9/cmfx/cmfx/user/passport/otp/code" + "github.com/issue9/cmfx/cmfx/user/passport/otp/totp" ) func Exec(name, version string) error { @@ -86,13 +84,13 @@ func initServer(name, ver string, o *server.Options, user *Config, action string switch action { case "serve": adminL := admin.Load(adminMod, user.Admin, uploadSaver) - smtpAuth := smtp.PlainAuth("id", "username", "password", "smtp@example.com") - smtpAdpater := code.New(adminL.Module(), 5*time.Minute, "smtp", nil, code.NewSMTPSender("code", "smtp@example.com", "server@example.com", "%%code%%", smtpAuth), web.Phrase("smtp valid")) - adminL.Passport().Register(smtpAdpater) + adminL.Passport().Register(totp.New(adminL.Module(), "totp", web.Phrase("TOTP passport"))) system.Load(systemMod, user.System, adminL) case "install": adminL := admin.Install(adminMod, user.Admin) + totp.Install(adminL.Module(), "totp") + system.Install(systemMod, user.System, adminL) case "upgrade": panic("not implements") diff --git a/cmfx/initial/initial.go b/cmfx/initial/initial.go index a5f2df67..3740b0e2 100644 --- a/cmfx/initial/initial.go +++ b/cmfx/initial/initial.go @@ -61,6 +61,7 @@ func problems(s web.Server) { &web.LocaleProblem{ID: cmfx.BadRequestInvalidBody, Title: web.StringPhrase("bad request invalid body"), Detail: web.StringPhrase("bad request invalid body detail")}, &web.LocaleProblem{ID: cmfx.BadRequestBodyTooLarger, Title: web.StringPhrase("bad request body too Larger"), Detail: web.StringPhrase("bad request body too Larger detail")}, &web.LocaleProblem{ID: cmfx.BadRequestBodyNotAllowed, Title: web.StringPhrase("bad request body not allowed"), Detail: web.StringPhrase("bad request body not allowed detail")}, + &web.LocaleProblem{ID: cmfx.BadRequestLastPassport, Title: web.StringPhrase("bad request last passport"), Detail: web.StringPhrase("bad request last passport detail")}, ).Add(http.StatusUnauthorized, &web.LocaleProblem{ID: cmfx.UnauthorizedInvalidState, Title: web.StringPhrase("unauthorized invalid state"), Detail: web.StringPhrase("unauthorized invalid state detail")}, &web.LocaleProblem{ID: cmfx.UnauthorizedInvalidToken, Title: web.StringPhrase("unauthorized invalid token"), Detail: web.StringPhrase("unauthorized invalid token detail")}, diff --git a/cmfx/locales/und.yaml b/cmfx/locales/und.yaml index f3abffac..fc5517f6 100755 --- a/cmfx/locales/und.yaml +++ b/cmfx/locales/und.yaml @@ -70,6 +70,12 @@ messages: - key: bad request invalid query detail message: msg: bad request invalid query detail + - key: bad request last passport + message: + msg: bad request last passport + - key: bad request last passport detail + message: + msg: bad request last passport detail - key: can not do it for super message: msg: can not do it for super @@ -79,12 +85,6 @@ messages: - key: censor setting message: msg: censor setting - - key: change password - message: - msg: change password - - key: created time - message: - msg: created time - key: del backup database file message: msg: del backup database file @@ -130,12 +130,9 @@ messages: - key: general setting message: msg: general setting - - key: get admin info api + - key: generator code for %s message: - msg: get admin info api - - key: get admin list api - message: - msg: get admin list api + msg: generator code for %s - key: get admins message: msg: get admins @@ -247,12 +244,9 @@ messages: - key: only for super detail message: msg: only for super detail - - key: passport adapter description - message: - msg: passport adapter description - - key: passport adapter id + - key: passport adapter not found message: - msg: passport adapter id + msg: passport adapter not found - key: password mode message: msg: password mode @@ -298,15 +292,9 @@ messages: - key: roles not exists message: msg: roles not exists - - key: same of new and old password - message: - msg: same of new and old password - - key: sex - message: - msg: sex - - key: state + - key: smtp valid message: - msg: state + msg: smtp valid - key: strength invalid message: msg: strength invalid diff --git a/cmfx/locales/zh.yaml b/cmfx/locales/zh.yaml index 487be6c3..f401352e 100644 --- a/cmfx/locales/zh.yaml +++ b/cmfx/locales/zh.yaml @@ -78,6 +78,12 @@ messages: message: msg: | 查询参数格式不正确,类似于无效的路径参数,大概率是前端提交的类型有误。 + - key: bad request last passport + message: + msg: 只剩下一种认证方式了 + - key: bad request last passport detail + message: + msg: 最后一种认证方式是不能删除的。一个账号最起码保存一种认证方式,才能进行登录操作。 - key: can not do it for super message: msg: 不能对超级管理使用有此操作 @@ -87,12 +93,6 @@ messages: - key: censor setting message: msg: 内容审核设置 - - key: change password - message: - msg: 修改密码 - - key: created time - message: - msg: created time - key: del backup database file message: msg: 删除备份的数据库文件 @@ -138,12 +138,9 @@ messages: - key: general setting message: msg: 常规设置 - - key: get admin info api + - key: generator code for %s message: - msg: 获取管理员信息 - - key: get admin list api - message: - msg: 获取管理员列表 + msg: 为 %s 生成验证码 - key: get admins message: msg: 查看管理员信息 @@ -257,12 +254,9 @@ messages: - key: only for super detail message: msg: 某些权限只有超级管理员才拥有,比如转让超级管理员操作。 - - key: passport adapter description - message: - msg: 登录验证器的描述 - - key: passport adapter id + - key: passport adapter not found message: - msg: 登录验证器的 ID + msg: 未找到指定的适配器 - key: password mode message: msg: 密码登录 @@ -302,15 +296,9 @@ messages: - key: roles not exists message: msg: 角色不存在 - - key: same of new and old password - message: - msg: 新旧密码是相同的 - - key: sex - message: - msg: 性别 - - key: state + - key: smtp valid message: - msg: 状态 + msg: smtp valid - key: strength invalid message: msg: 密码强度不够 diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index 9d802e13..1250127b 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -206,10 +206,7 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { mod.Router().Prefix(m.URLPrefix(), m). Delete("/passports/{type}", m.deletePassport). Post("/passports/{type}", m.postPassport). - Patch("/passports/{type}", m.patchPassport). - Delete("/admins/{id:digit}/passports/{type}", m.deleteAdminPassport). - Post("/admins/{id:digit}/passports/{type}", m.postAdminPassport). - Patch("/admins/{id:digit}/passports/{type}", m.patchAdminPassport) + Post("/passports/{type}/code", m.postCurrentPassportCode) // upload up := upload.New(saver, o.Upload.Size, o.Upload.Exts...) diff --git a/cmfx/modules/admin/route_admins.go b/cmfx/modules/admin/route_admins.go index 65a297f5..248f0b35 100644 --- a/cmfx/modules/admin/route_admins.go +++ b/cmfx/modules/admin/route_admins.go @@ -28,11 +28,11 @@ type adminInfoVO struct { Passports []*respPassportIdentity `json:"passports" xml:"passports" cbor:"passports"` } -type respPassportIdentity struct { - Name string `json:"name" xml:"name" cbor:"name"` - Identity string `json:"identity" xml:"identity" cbor:"identity"` -} - +// # API GET /admins/{id} 获取指定的管理员账号 +// +// @tag admin +// @path id int 管理的 ID +// @resp 200 * respAdminInfo func (m *Module) getAdmin(ctx *web.Context) web.Responser { id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) if resp != nil { @@ -61,11 +61,11 @@ func (m *Module) getAdmin(ctx *web.Context) web.Responser { ps := make([]*respPassportIdentity, 0) for k, v := range m.Passport().Identities(id) { ps = append(ps, &respPassportIdentity{ - Name: k, - Identity: v, + ID: k, + Username: v, }) } - slices.SortFunc(ps, func(a, b *respPassportIdentity) int { return cmp.Compare(a.Name, b.Name) }) // 排序,尽量使输出的内容相同 + slices.SortFunc(ps, func(a, b *respPassportIdentity) int { return cmp.Compare(a.ID, b.ID) }) // 排序,尽量使输出的内容相同 return web.OK(&adminInfoVO{ ctxInfoWithRoleState: ctxInfoWithRoleState{ @@ -148,7 +148,7 @@ func (m *Module) getAdmins(ctx *web.Context) web.Responser { } func (m *Module) patchAdmin(ctx *web.Context) web.Responser { - u, resp := m.getActiveUserFromContext(ctx) + u, resp := m.getUserFromPath(ctx) if resp != nil { return resp } diff --git a/cmfx/modules/admin/route_auth.go b/cmfx/modules/admin/route_auth.go index a2a1c9bb..bd9ab92e 100644 --- a/cmfx/modules/admin/route_auth.go +++ b/cmfx/modules/admin/route_auth.go @@ -6,40 +6,22 @@ package admin import ( "cmp" + "errors" "slices" "github.com/issue9/web" - "github.com/issue9/web/filter" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/filters" - "github.com/issue9/cmfx/cmfx/locales" "github.com/issue9/cmfx/cmfx/user" "github.com/issue9/cmfx/cmfx/user/passport" ) -type queryLogin struct { - m *Module - Type string `query:"type,password"` -} - -func (q *queryLogin) Filter(c *web.FilterContext) { - v := func(s string) bool { return q.m.Passport().Get(s) != nil } - c.Add(filter.NewBuilder(filter.V(v, locales.InvalidValue))("type", &q.Type)) -} - // # API POST /login 管理员登录 // @tag admin auth -// @query queryLogin -// @req * github.com/issue9/cmfx/cmfx/user.reqAccount +// @req * github.com/issue9/cmfx/cmfx/user.Account // @resp 201 * github.com/issue9/webuse/v7/middlewares/auth/token.Response func (m *Module) postLogin(ctx *web.Context) web.Responser { - q := &queryLogin{m: m} - if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { - return resp - } - - return m.user.Login(q.Type, ctx, nil, func(u *user.User) { + return m.user.Login(ctx, nil, func(u *user.User) { m.loginEvent.Publish(false, u) }) } @@ -60,7 +42,7 @@ func (m *Module) putToken(ctx *web.Context) web.Responser { return m.user.RefreshToken(ctx) } -type respAdapters struct { +type respPassportAdapters struct { ID string `json:"id" cbor:"id" xml:"id"` Desc string `json:"desc" cbor:"desc" xml:"desc"` } @@ -69,22 +51,22 @@ type respAdapters struct { // @tag admin auth // @resp 200 * respAdapters func (m *Module) getPassports(ctx *web.Context) web.Responser { - adapters := make([]*respAdapters, 0) + adapters := make([]*respPassportAdapters, 0) for k, v := range m.Passport().All(ctx.LocalePrinter()) { - adapters = append(adapters, &respAdapters{ + adapters = append(adapters, &respPassportAdapters{ ID: k, Desc: v, }) } - slices.SortFunc(adapters, func(a, b *respAdapters) int { return cmp.Compare(a.ID, b.ID) }) + slices.SortFunc(adapters, func(a, b *respPassportAdapters) int { return cmp.Compare(a.ID, b.ID) }) return web.OK(adapters) } // # api POST /passports/{type}/code/{identity} 请求新的验证码 // @tag admin auth -// @path id id 管理员的 ID // @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 +// @path identity string 在 {type} 适配器中注册的用户标记,如果不存在将返回 404; // @resp 201 * {} func (m *Module) postPassportCode(ctx *web.Context) web.Responser { typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) @@ -103,139 +85,75 @@ func (m *Module) postPassportCode(ctx *web.Context) web.Responser { } uid, err := a.UID(identity) - if err != nil { + if errors.Is(err, passport.ErrIdentityNotExists()) { + return ctx.Problem(cmfx.NotFound) + } else if err != nil { return ctx.Error(err, "") } + if err := a.Update(uid); err != nil { return ctx.Error(err, "") } return web.Created(nil, "") } -// # api delete /passports/{type} 取消当前用户与登录方式 type 之间的关联 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @resp 204 * {} -func (m *Module) deletePassport(ctx *web.Context) web.Responser { - return m.delAdminPassport(ctx, m.getPassport) -} - -// # api delete /admins/{id}/passports/{type} 取消用户 id 与登录方式 type 之间的关联 +// # api POST /passports/{type}/code 请求新的验证码 // @tag admin auth // @path id id 管理员的 ID // @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @resp 204 * {} -func (m *Module) deleteAdminPassport(ctx *web.Context) web.Responser { - return m.delAdminPassport(ctx, m.getAdminPassport) -} - -func (m *Module) delAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { - u, a, resp := f(ctx) +// @resp 201 * {} +func (m *Module) postCurrentPassportCode(ctx *web.Context) web.Responser { + u, a, resp := m.getPassport(ctx) if resp != nil { return resp } - if err := a.Delete(u.ID); err != nil { + if err := a.Update(u.ID); err != nil { return ctx.Error(err, "") } - return web.NoContent() -} - -// 验证用户当次请求的数据 -type reqValidation struct { - Type string `json:"type" cbor:"type" xml:"type"` - Identity string `json:"identity" cbor:"identity" xml:"identity"` - Password string `json:"password" cbor:"password" xml:"password"` -} - -func (v *reqValidation) Filter(c *web.FilterContext) { - c.Add(filters.NotEmpty("type", &v.Type)). - Add(filters.NotEmpty("identity", &v.Identity)). - Add(filters.NotEmpty("password", &v.Password)) -} - -func (v *reqValidation) valid(ctx *web.Context, u *user.User, p *passport.Passport) bool { - adp := p.Get(v.Type) - if adp == nil { - return false - } - - uid, _, err := adp.Valid(v.Identity, v.Password, ctx.Begin()) - return err == nil && uid == u.ID -} - -type reqPassport struct { - // 用于验证的数据 - Validate *reqValidation `json:"validate" xml:"validate" cbor:"validate"` - - // 被修改的数据 - - Identity string `json:"identity" cbor:"identity" xml:"identity"` - Code string `json:"code" cbor:"code" xml:"code"` -} - -// # api POST /passports/{type} 建立当前用户与登录方式 type 之间的关联 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @req * reqPassport -// @resp 201 * {} -func (m *Module) postPassport(ctx *web.Context) web.Responser { - return m.addAdminPassport(ctx, m.getPassport) + return web.Created(nil, "") } -// # api POST /admins/{id}/passports/{type} 建立用户 id 与登录方式 type 之间的关联 +// # api delete /passports/{type} 取消当前用户与登录方式 type 之间的关联 // @tag admin auth -// @path id id 管理员的 ID // @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @req * reqPassport -// @resp 201 * {} -func (m *Module) postAdminPassport(ctx *web.Context) web.Responser { - return m.addAdminPassport(ctx, m.getAdminPassport) -} - -func (m *Module) addAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { - u, a, resp := f(ctx) +// @resp 204 * {} +func (m *Module) deletePassport(ctx *web.Context) web.Responser { + u, a, resp := m.getPassport(ctx) if resp != nil { return resp } - data := &reqPassport{} - if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { - return resp + // 判断是否为最后个验证方式 + cnt := 0 + for range m.Passport().Identities(u.ID) { + cnt++ + if cnt > 1 { // 多余一个了,之后的就没必要了统计了。 + break + } } - - if !data.Validate.valid(ctx, u, m.Passport()) { - return ctx.Problem(cmfx.Unauthorized) + if cnt <= 1 { + return ctx.Problem(cmfx.BadRequestLastPassport) } - if err := a.Add(u.ID, data.Identity, data.Code, ctx.Begin()); err != nil { + if err := a.Delete(u.ID); err != nil { return ctx.Error(err, "") } - - return web.Created(nil, "") + return web.NoContent() } -// # api patch /passports/{type} 修改当前用户的登录方式 type 的认证数据 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @req * reqPassport -// @resp 204 * {} -func (m *Module) patchPassport(ctx *web.Context) web.Responser { - return m.editAdminPassport(ctx, m.getPassport) +type reqPassport struct { + Username string `json:"username" cbor:"username" xml:"username"` + Password string `json:"password" cbor:"password" xml:"password"` } -// # api patch /admins/{id}/passports/{type} 修改用户 id 的登录方式 type 的认证数据 +// # api POST /passports/{type} 建立当前用户与登录方式 type 之间的关联 // @tag admin auth -// @path id id 管理员的 ID // @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 // @req * reqPassport -// @resp 204 * {} -func (m *Module) patchAdminPassport(ctx *web.Context) web.Responser { - return m.editAdminPassport(ctx, m.getAdminPassport) -} - -func (m *Module) editAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u *user.User, a passport.Adapter, resp web.Responser)) web.Responser { - u, adp, resp := f(ctx) +// @resp 201 * {} +func (m *Module) postPassport(ctx *web.Context) web.Responser { + u, a, resp := m.getPassport(ctx) if resp != nil { return resp } @@ -245,18 +163,11 @@ func (m *Module) editAdminPassport(ctx *web.Context, f func(ctx *web.Context) (u return resp } - if !data.Validate.valid(ctx, u, m.Passport()) { - return ctx.Problem(cmfx.Unauthorized) - } - - if err := adp.Delete(u.ID); err != nil { - return ctx.Error(err, "") - } - if err := adp.Add(u.ID, data.Identity, data.Code, ctx.Begin()); err != nil { + if err := a.Add(u.ID, data.Username, data.Password, ctx.Begin()); err != nil { return ctx.Error(err, "") } - return web.NoContent() + return web.Created(nil, "") } func (m *Module) getPassport(ctx *web.Context) (*user.User, passport.Adapter, web.Responser) { @@ -275,26 +186,7 @@ func (m *Module) getPassport(ctx *web.Context) (*user.User, passport.Adapter, we return u, a, nil } -func (m *Module) getAdminPassport(ctx *web.Context) (*user.User, passport.Adapter, web.Responser) { - u, resp := m.getActiveUserFromContext(ctx) - if resp != nil { - return nil, nil, resp - } - - typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) - if resp != nil { - return nil, nil, resp - } - - a := m.Passport().Get(typ) - if a == nil { - return nil, nil, ctx.Problem(cmfx.NotFound) - } - - return u, a, nil -} - -func (m *Module) getActiveUserFromContext(ctx *web.Context) (*user.User, web.Responser) { +func (m *Module) getUserFromPath(ctx *web.Context) (*user.User, web.Responser) { id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) if resp != nil { return nil, resp diff --git a/cmfx/modules/admin/route_current.go b/cmfx/modules/admin/route_current.go index 493c4566..4ad7e17e 100644 --- a/cmfx/modules/admin/route_current.go +++ b/cmfx/modules/admin/route_current.go @@ -5,22 +5,51 @@ package admin import ( + "cmp" + "slices" + "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" ) +type respPassportIdentity struct { + ID string `json:"id" xml:"id" cbor:"id"` + Username string `json:"username" xml:"username" cbor:"username"` +} + +type respInfoWithPassport struct { + info + Passports []*respPassportIdentity `json:"passports" xml:"passports" cbor:"passports"` +} + +// # api get /info 获取当前登用户的信息 +// @tag admin +// @resp 200 * respInfoWithPassport func (m *Module) getInfo(ctx *web.Context) web.Responser { u := m.CurrentUser(ctx) - mod := &info{ID: u.ID} - f, err := m.Module().DB().Select(mod) + infomation := &info{ID: u.ID} + f, err := m.Module().DB().Select(infomation) if err != nil { return ctx.Error(err, "") } if !f { return ctx.NotFound() } - return web.OK(mod) + + ps := make([]*respPassportIdentity, 0) + for k, v := range m.Passport().Identities(u.ID) { + ps = append(ps, &respPassportIdentity{ + ID: k, + Username: v, + }) + } + slices.SortFunc(ps, func(a, b *respPassportIdentity) int { return cmp.Compare(a.ID, b.ID) }) // 排序,尽量使输出的内容相同 + + return web.OK(&respInfoWithPassport{ + info: *infomation, + Passports: ps, + }) } func (m *Module) patchInfo(ctx *web.Context) web.Responser { diff --git a/cmfx/user/passport/otp/totp/install.go b/cmfx/user/passport/otp/totp/install.go index 4eb76970..7f2b5788 100644 --- a/cmfx/user/passport/otp/totp/install.go +++ b/cmfx/user/passport/otp/totp/install.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -package hotp +package totp import ( "github.com/issue9/web" diff --git a/cmfx/user/passport/otp/totp/install_test.go b/cmfx/user/passport/otp/totp/install_test.go index 415503b4..f8151123 100644 --- a/cmfx/user/passport/otp/totp/install_test.go +++ b/cmfx/user/passport/otp/totp/install_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -package hotp +package totp import ( "testing" diff --git a/cmfx/user/passport/otp/totp/models.go b/cmfx/user/passport/otp/totp/models.go index b65f1a0f..11469ced 100644 --- a/cmfx/user/passport/otp/totp/models.go +++ b/cmfx/user/passport/otp/totp/models.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -package hotp +package totp import "time" diff --git a/cmfx/user/passport/otp/totp/totp.go b/cmfx/user/passport/otp/totp/totp.go index 477ea058..ed7f2472 100644 --- a/cmfx/user/passport/otp/totp/totp.go +++ b/cmfx/user/passport/otp/totp/totp.go @@ -5,7 +5,7 @@ // Package hotp 提供基于 [TOTP] 的 [passport.Adapter] 实现 // // [TOTP]: https://datatracker.ietf.org/doc/html/rfc6238 -package hotp +package totp import ( "crypto/hmac" @@ -47,6 +47,8 @@ func (p *totp) ID() string { return p.id } func (p *totp) Description() web.LocaleStringer { return p.desc } // Add 添加账号 +// +// identity 表示账号,在登录页上,需要通过账号来判定验证码的关联对象。 func (p *totp) Add(uid int64, identity, _ string, now time.Time) error { n, err := p.db.Where("uid=?", uid).Count(&modelTOTP{}) if err != nil { diff --git a/cmfx/user/passport/otp/totp/totp_test.go b/cmfx/user/passport/otp/totp/totp_test.go index 9d7b2efc..e37a787b 100644 --- a/cmfx/user/passport/otp/totp/totp_test.go +++ b/cmfx/user/passport/otp/totp/totp_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -package hotp +package totp import ( "testing" diff --git a/cmfx/user/token.go b/cmfx/user/token.go index bc01932d..94a73518 100644 --- a/cmfx/user/token.go +++ b/cmfx/user/token.go @@ -26,12 +26,14 @@ type AfterFunc = func(*User) // 登录需要提交的信息 type Account struct { XMLName struct{} `xml:"account" json:"-" cbor:"-"` - Username string `json:"username" xml:"username" yaml:"username" cbor:"username"` - Password string `json:"password" xml:"password" yaml:"password" cbor:"password"` + Type string `json:"type" xml:"type" cbor:"type"` + Username string `json:"username" xml:"username" cbor:"username"` + Password string `json:"password" xml:"password" cbor:"password"` } func (c *Account) Filter(v *web.FilterContext) { - v.Add(filters.NotEmpty("username", &c.Username)). + v.Add(filters.NotEmpty("type", &c.Type)). + Add(filters.NotEmpty("username", &c.Username)). Add(filters.NotEmpty("password", &c.Password)) } @@ -61,18 +63,18 @@ func (m *Module) SetState(tx *orm.Tx, u *User, s State) error { // Login 执行登录操作并在成功的情况下发放新的令牌 // -// 如果 reg 不为空,表示在验证成功,但是不存在用户数是执行注册服务,其原型如下: +// 如果 reg 不为空,表示在验证成功,但是不存在用户数时执行注册服务,其原型如下: // // func( uid int64) error // // uid 为新用户的 uid。 -func (m *Module) Login(typ string, ctx *web.Context, reg func(int64) error, after AfterFunc) web.Responser { +func (m *Module) Login(ctx *web.Context, reg func(int64) error, after AfterFunc) web.Responser { account := &Account{} if resp := ctx.Read(true, account, cmfx.BadRequestInvalidBody); resp != nil { return resp } - uid, identity, ok := m.passport.Valid(typ, account.Username, account.Password, ctx.Begin()) + uid, identity, ok := m.passport.Valid(account.Type, account.Username, account.Password, ctx.Begin()) if !ok { // 密码或账号错误 return ctx.Problem(cmfx.UnauthorizedInvalidAccount) } @@ -80,7 +82,7 @@ func (m *Module) Login(typ string, ctx *web.Context, reg func(int64) error, afte // 注册 if uid == 0 && reg != nil { var err error - if uid, err = m.NewUser(m.Passport().Get(typ), identity, account.Password, ctx.Begin()); err != nil { + if uid, err = m.NewUser(m.Passport().Get(account.Type), identity, account.Password, ctx.Begin()); err != nil { return ctx.Error(err, "") } @@ -100,7 +102,7 @@ func (m *Module) Login(typ string, ctx *web.Context, reg func(int64) error, afte } if !found { - ctx.Logs().DEBUG().Printf("数据库不同步,%s 存在于适配器 %s,但是不存在于用户列表数据库\n", account.Username, typ) + ctx.Logs().DEBUG().Printf("数据库不同步,%s 存在于适配器 %s,但是不存在于用户列表数据库\n", account.Username, account.Type) return ctx.Problem(cmfx.UnauthorizedInvalidAccount) } diff --git a/cmfx/user/token_test.go b/cmfx/user/token_test.go index 1cf50dbd..c8d7e507 100644 --- a/cmfx/user/token_test.go +++ b/cmfx/user/token_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "testing" "time" @@ -29,41 +30,21 @@ func TestLoader_Login(t *testing.T) { u := newModule(s) // 添加用于测试的验证码验证 - code.Install(u.Module(), "_code") + code.Install(u.Module(), "code") pc := code.New(u.Module(), time.Second, "code", nil, code.NewEmptySender(), web.Phrase("code")) u.Passport().Register(pc) a.NotError(pc.Add(0, "new", "password", time.Now())) s.Module().Router().Post("/login", func(ctx *web.Context) web.Responser { - q, err := ctx.Queries(true) - if err != nil { - return ctx.Error(err, "") - } - - switch q.String("type", "password") { - case "password": - output := &bytes.Buffer{} - resp := u.Login("password", ctx, func(id int64) error { - _, err := output.WriteString(strconv.FormatInt(id, 10)) - return err - }, func(_ *User) { - output.WriteString("after") - }) - - a.NotNil(resp).Equal(output.String(), "after") // 用户已经存在 - return resp - case "code": - output := &bytes.Buffer{} - resp := u.Login("code", ctx, func(id int64) error { - _, err := output.WriteString(strconv.FormatInt(id, 10)) - return err - }, func(_ *User) { output.WriteString("after") }) - - a.NotNil(resp).Equal(output.String(), "2after") // 注册的新用户 - return resp - default: - return ctx.NotImplemented() - } + output := &bytes.Buffer{} + resp := u.Login(ctx, func(id int64) error { + // 仅新用户才会访问到此 + _, err := output.WriteString(strconv.FormatInt(id, 10)) + return err + }, func(_ *User) { output.WriteString("after") }) + + a.NotNil(resp).True(strings.HasSuffix(output.String(), "after")) // 用户已经存在 + return resp }) // 测试 SetState @@ -92,7 +73,7 @@ func TestLoader_Login(t *testing.T) { //--------------------------- user 1 ------------------------------------- tk1 := &token.Response{} - s.Post("/login", []byte(`{"username":"admin","password":"password"}`)). + s.Post("/login", []byte(`{"type":"password","username":"admin","password":"password"}`)). Header(header.Accept, header.JSON). Header(header.ContentType, header.JSON+"; charset=utf-8"). Do(nil). @@ -116,8 +97,10 @@ func TestLoader_Login(t *testing.T) { //--------------------------- user 2 ------------------------------------- + // NOTE: 该用户不存在 + tk1 = &token.Response{} - s.Post("/login?type=code", []byte(`{"username":"new","password":"password"}`)). + s.Post("/login?type=code", []byte(`{"type":"code","username":"new","password":"password"}`)). Header(header.Accept, header.JSON). Header(header.ContentType, header.JSON+"; charset=utf-8"). Do(nil). From 2991623f36685560c39eeba104975d337d52e7be Mon Sep 17 00:00:00 2001 From: caixw Date: Fri, 1 Nov 2024 14:00:39 +0800 Subject: [PATCH 06/11] =?UTF-8?q?refactor(cmfx):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BA=86=E9=BB=98=E8=AE=A4=E5=AF=86=E7=A0=81=E7=9A=84=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/modules/admin/admintest/admintest.go | 1 - cmfx/modules/admin/config.go | 11 ----------- cmfx/modules/admin/install.go | 8 ++++---- cmfx/modules/admin/install_test.go | 1 - cmfx/modules/admin/module.go | 6 ++---- cmfx/user/passport/oauth/install.go | 3 ++- cmfx/user/passport/oauth/oauth.go | 7 ++----- cmfx/user/passport/otp/code/code.go | 7 ++----- cmfx/user/passport/otp/code/install.go | 3 ++- cmfx/user/passport/otp/totp/install.go | 3 ++- cmfx/user/passport/otp/totp/totp.go | 7 ++----- cmfx/user/passport/password/install.go | 3 ++- cmfx/user/passport/password/password.go | 7 ++----- cmfx/user/passport/utils/utils.go | 17 +++++++++++++++++ 14 files changed, 39 insertions(+), 45 deletions(-) create mode 100644 cmfx/user/passport/utils/utils.go diff --git a/cmfx/modules/admin/admintest/admintest.go b/cmfx/modules/admin/admintest/admintest.go index 2b1309a7..0b2df58a 100644 --- a/cmfx/modules/admin/admintest/admintest.go +++ b/cmfx/modules/admin/admintest/admintest.go @@ -30,7 +30,6 @@ func NewModule(s *test.Suite) *admin.Module { AccessExpired: 60 * config.Duration(time.Second), RefreshExpired: 120 * config.Duration(time.Second), }, - DefaultPassword: "123", Upload: &admin.Upload{ Size: 1024 * 1024 * 1024, Exts: []string{".jpg"}, diff --git a/cmfx/modules/admin/config.go b/cmfx/modules/admin/config.go index e8cb976a..5eea2723 100644 --- a/cmfx/modules/admin/config.go +++ b/cmfx/modules/admin/config.go @@ -18,13 +18,6 @@ type Config struct { // User 用户相关的配置 User *user.Config `json:"user" xml:"user" yaml:"user"` - // DefaultPassword 默认的密码 - // - // 重置密码的操作将会将用户的密码重置为该值。 - // - // 如果未设置,该值将被设置为 123 - DefaultPassword string `json:"defaultPassword" xml:"defaultPassword" yaml:"defaultPassword"` - // 上传接口的相关配置 Upload *Upload `json:"upload" xml:"upload" yaml:"upload"` } @@ -47,10 +40,6 @@ func (c *Config) SanitizeConfig() *web.FieldError { return err.AddFieldParent("user") } - if c.DefaultPassword == "" { - c.DefaultPassword = "123" - } - if c.Upload != nil { if err := c.Upload.SanitizeConfig(); err != nil { return err.AddFieldParent("upload") diff --git a/cmfx/modules/admin/install.go b/cmfx/modules/admin/install.go index 268c73f6..359c9d31 100644 --- a/cmfx/modules/admin/install.go +++ b/cmfx/modules/admin/install.go @@ -52,7 +52,7 @@ func Install(mod *cmfx.Module, o *Config) *Module { }, }, Username: "admin", - Password: o.DefaultPassword, + Password: "123", }, { ctxInfoWithRoleState: ctxInfoWithRoleState{ @@ -63,7 +63,7 @@ func Install(mod *cmfx.Module, o *Config) *Module { }, }, Username: "u1", - Password: o.DefaultPassword, + Password: "123", }, { ctxInfoWithRoleState: ctxInfoWithRoleState{ @@ -74,7 +74,7 @@ func Install(mod *cmfx.Module, o *Config) *Module { }, }, Username: "u2", - Password: o.DefaultPassword, + Password: "123", }, { ctxInfoWithRoleState: ctxInfoWithRoleState{ @@ -84,7 +84,7 @@ func Install(mod *cmfx.Module, o *Config) *Module { }, }, Username: "u3", - Password: o.DefaultPassword, + Password: "123", }, } diff --git a/cmfx/modules/admin/install_test.go b/cmfx/modules/admin/install_test.go index 9adec5f5..254fbdd2 100644 --- a/cmfx/modules/admin/install_test.go +++ b/cmfx/modules/admin/install_test.go @@ -26,7 +26,6 @@ func TestInstall(t *testing.T) { AccessExpired: 60, RefreshExpired: 120, }, - DefaultPassword: "123", Upload: &Upload{ Size: 1024 * 1024 * 1024, Exts: []string{".jpg"}, diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index 1250127b..2b71d25a 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -30,8 +30,7 @@ import ( const passportTypePassword = "passwords" // 采用密码登录的 type Module struct { - user *user.Module - defaultPassword string + user *user.Module roleGroup *rbac.RoleGroup @@ -53,8 +52,7 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { u.Passport().Register(password.New(mod, passportTypePassword, 8, web.StringPhrase("password mode"))) m := &Module{ - user: u, - defaultPassword: o.DefaultPassword, + user: u, uploadField: o.Upload.Field, diff --git a/cmfx/user/passport/oauth/install.go b/cmfx/user/passport/oauth/install.go index 480e9f84..9c8aaa77 100644 --- a/cmfx/user/passport/oauth/install.go +++ b/cmfx/user/passport/oauth/install.go @@ -8,10 +8,11 @@ import ( "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) func Install(mod *cmfx.Module, tableName string) { - db := buildDB(mod, tableName) + db := utils.BuildDB(mod, tableName) if err := db.Create(&modelOAuth{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } diff --git a/cmfx/user/passport/oauth/oauth.go b/cmfx/user/passport/oauth/oauth.go index 973f54e7..fac68257 100644 --- a/cmfx/user/passport/oauth/oauth.go +++ b/cmfx/user/passport/oauth/oauth.go @@ -24,6 +24,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) // UserInfo 表示 OAuth 登录后获取的用户信息 @@ -47,16 +48,12 @@ type OAuth[T UserInfo] struct { desc web.LocaleStringer } -func buildDB(mod *cmfx.Module, tableName string) *orm.DB { - return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) -} - // New 声明 [OAuth] 对象 // // id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; func New[T UserInfo](mod *cmfx.Module, id string, c *oauth2.Config, g GetUserInfoFunc[T], desc web.LocaleStringer) *OAuth[T] { return &OAuth[T]{ - db: buildDB(mod, id), + db: utils.BuildDB(mod, id), state: mod.Server().UniqueID(), config: c, f: g, diff --git a/cmfx/user/passport/otp/code/code.go b/cmfx/user/passport/otp/code/code.go index 152b4e5c..3304b0b5 100644 --- a/cmfx/user/passport/otp/code/code.go +++ b/cmfx/user/passport/otp/code/code.go @@ -13,6 +13,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) type code struct { @@ -24,10 +25,6 @@ type code struct { desc web.LocaleStringer } -func buildDB(mod *cmfx.Module, tableName string) *orm.DB { - return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) -} - // New 声明基于验证码的验证方法 // // id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; @@ -39,7 +36,7 @@ func New(mod *cmfx.Module, expired time.Duration, id string, gen Generator, send } return &code{ - db: buildDB(mod, id), + db: utils.BuildDB(mod, id), sender: sender, expired: expired, gen: gen, diff --git a/cmfx/user/passport/otp/code/install.go b/cmfx/user/passport/otp/code/install.go index a9123057..0245d9da 100644 --- a/cmfx/user/passport/otp/code/install.go +++ b/cmfx/user/passport/otp/code/install.go @@ -8,10 +8,11 @@ import ( "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) func Install(mod *cmfx.Module, tableName string) { - db := buildDB(mod, tableName) + db := utils.BuildDB(mod, tableName) if err := db.Create(&modelCode{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } diff --git a/cmfx/user/passport/otp/totp/install.go b/cmfx/user/passport/otp/totp/install.go index 7f2b5788..dd5bdf67 100644 --- a/cmfx/user/passport/otp/totp/install.go +++ b/cmfx/user/passport/otp/totp/install.go @@ -8,10 +8,11 @@ import ( "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) func Install(mod *cmfx.Module, tableName string) { - db := buildDB(mod, tableName) + db := utils.BuildDB(mod, tableName) if err := db.Create(&modelTOTP{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } diff --git a/cmfx/user/passport/otp/totp/totp.go b/cmfx/user/passport/otp/totp/totp.go index ed7f2472..ac38c94d 100644 --- a/cmfx/user/passport/otp/totp/totp.go +++ b/cmfx/user/passport/otp/totp/totp.go @@ -20,6 +20,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) type totp struct { @@ -33,7 +34,7 @@ type totp struct { // // [TOTP]: https://datatracker.ietf.org/doc/html/rfc6238 func New(mod *cmfx.Module, id string, desc web.LocaleStringer) passport.Adapter { - db := buildDB(mod, id) + db := utils.BuildDB(mod, id) return &totp{ mod: mod, db: db, @@ -161,7 +162,3 @@ func (p *totp) UID(identity string) (int64, error) { return mod.UID, nil } - -func buildDB(mod *cmfx.Module, tableName string) *orm.DB { - return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) -} diff --git a/cmfx/user/passport/password/install.go b/cmfx/user/passport/password/install.go index 4fa6c48a..71633a96 100644 --- a/cmfx/user/passport/password/install.go +++ b/cmfx/user/passport/password/install.go @@ -8,10 +8,11 @@ import ( "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) func Install(mod *cmfx.Module, tableName string) { - db := buildDB(mod, tableName) + db := utils.BuildDB(mod, tableName) if err := db.Create(&modelPassword{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } diff --git a/cmfx/user/passport/password/password.go b/cmfx/user/passport/password/password.go index 5ca4e6e3..10c937f5 100644 --- a/cmfx/user/passport/password/password.go +++ b/cmfx/user/passport/password/password.go @@ -15,6 +15,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/user/passport/utils" ) type password struct { @@ -24,10 +25,6 @@ type password struct { desc web.LocaleStringer } -func buildDB(mod *cmfx.Module, tableName string) *orm.DB { - return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) -} - // New 声明基于密码的验证方法 // // id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; @@ -37,7 +34,7 @@ func New(mod *cmfx.Module, id string, cost int, desc web.LocaleStringer) passpor if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { cost = bcrypt.DefaultCost } - db := buildDB(mod, id) + db := utils.BuildDB(mod, id) return &password{db: db, cost: cost, id: id, desc: desc} } diff --git a/cmfx/user/passport/utils/utils.go b/cmfx/user/passport/utils/utils.go new file mode 100644 index 00000000..90ab5f4a --- /dev/null +++ b/cmfx/user/passport/utils/utils.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +// Package utils 提供供 passport 的一些工具 +package utils + +import ( + "github.com/issue9/orm/v6" + + "github.com/issue9/cmfx/cmfx" +) + +// BuildDB 根据表名生成 [orm.DB] 对象 +func BuildDB(mod *cmfx.Module, tableName string) *orm.DB { + return mod.DB().New(mod.DB().TablePrefix() + "_auth_" + tableName) +} From c14f4c80fcff5dd4407ae0f2fa6633125f2c2614 Mon Sep 17 00:00:00 2001 From: caixw Date: Mon, 4 Nov 2024 14:00:08 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs(cmfx):=20=E4=BF=AE=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=86=85=E5=AE=B9=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/modules/admin/route_auth.go | 2 +- cmfx/user/passport/passport.go | 4 ++-- cmfx/user/passport/passport_test.go | 4 ++-- cmfx/user/token.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmfx/modules/admin/route_auth.go b/cmfx/modules/admin/route_auth.go index bd9ab92e..73a8a2d4 100644 --- a/cmfx/modules/admin/route_auth.go +++ b/cmfx/modules/admin/route_auth.go @@ -49,7 +49,7 @@ type respPassportAdapters struct { // # api GET /passports 支持的登录验证方式 // @tag admin auth -// @resp 200 * respAdapters +// @resp 200 * respPassportAdapters func (m *Module) getPassports(ctx *web.Context) web.Responser { adapters := make([]*respPassportAdapters, 0) for k, v := range m.Passport().All(ctx.LocalePrinter()) { diff --git a/cmfx/user/passport/passport.go b/cmfx/user/passport/passport.go index 4b34842e..f80279f8 100644 --- a/cmfx/user/passport/passport.go +++ b/cmfx/user/passport/passport.go @@ -96,8 +96,8 @@ func (p *Passport) Identities(uid int64) iter.Seq2[string, string] { } } -// ClearUser 清空与 uid 相关的所有登录信息 -func (p *Passport) ClearUser(uid int64) error { +// DeleteUser 清空与 uid 相关的所有登录信息 +func (p *Passport) DeleteUser(uid int64) error { for _, info := range p.adapters { if err := info.Delete(uid); err != nil { return err diff --git a/cmfx/user/passport/passport_test.go b/cmfx/user/passport/passport_test.go index 3c1d4776..b95f1df7 100644 --- a/cmfx/user/passport/passport_test.go +++ b/cmfx/user/passport/passport_test.go @@ -66,7 +66,7 @@ func TestPassport(t *testing.T) { // p.DeleteUser - a.NotError(p.ClearUser(1111)) // 不存在该用户 - a.NotError(p.ClearUser(1024)) + a.NotError(p.DeleteUser(1111)) // 不存在该用户 + a.NotError(p.DeleteUser(1024)) a.Empty(maps.Collect(p.Identities(1024))) } diff --git a/cmfx/user/token.go b/cmfx/user/token.go index 94a73518..0561c55f 100644 --- a/cmfx/user/token.go +++ b/cmfx/user/token.go @@ -52,7 +52,7 @@ func (m *Module) SetState(tx *orm.Tx, u *User, s State) error { } if s == StateDeleted { // 删除所有的登录信息 - if err := m.Passport().ClearUser(u.ID); err != nil { + if err := m.Passport().DeleteUser(u.ID); err != nil { m.mod.Server().Logs().ERROR().Error(err) // 记录错误,但是不退出 } } From 4f225191549d5faf53ef8e9f1094bf59992d6676 Mon Sep 17 00:00:00 2001 From: caixw Date: Sat, 30 Nov 2024 02:15:56 +0800 Subject: [PATCH 08/11] =?UTF-8?q?refactor(cmfx/user):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/cmfx.go | 1 - cmfx/initial/cmd/cmd.go | 20 +- cmfx/initial/doc.go | 27 ++ cmfx/initial/initial.go | 1 - cmfx/initial/test/suite.go | 9 +- cmfx/initial/test/test.go | 9 +- cmfx/locales/und.yaml | 167 +++++++---- cmfx/locales/zh.yaml | 173 +++++++---- cmfx/modules/admin/admintest/admintest.go | 10 +- cmfx/modules/admin/install.go | 6 +- cmfx/modules/admin/models.go | 4 +- cmfx/modules/admin/module.go | 32 +-- cmfx/modules/admin/route_admins.go | 31 +- cmfx/modules/admin/route_auth.go | 204 ------------- cmfx/modules/admin/route_current.go | 12 +- cmfx/user/config.go | 13 +- cmfx/user/config_test.go | 15 +- cmfx/user/install.go | 2 +- cmfx/user/models.go | 34 ++- cmfx/user/module.go | 66 ++++- cmfx/user/module_test.go | 21 +- cmfx/user/passport.go | 88 ++++++ cmfx/user/passport/adapter.go | 64 ----- cmfx/user/passport/adaptertest/adaptertest.go | 67 ----- cmfx/user/passport/errors.go | 34 --- cmfx/user/passport/oauth/install.go | 19 -- cmfx/user/passport/oauth/models.go | 16 -- cmfx/user/passport/oauth/oauth.go | 140 --------- cmfx/user/passport/oauth/oauth_test.go | 15 - cmfx/user/passport/oauth/twitter.go | 54 ---- cmfx/user/passport/otp/code/code.go | 272 ++++++++++++------ cmfx/user/passport/otp/code/code_test.go | 69 ++++- .../passport/otp/code/codetest/codetest.go | 22 ++ cmfx/user/passport/otp/code/install.go | 2 +- cmfx/user/passport/otp/code/models.go | 53 +++- cmfx/user/passport/otp/code/models_test.go | 12 +- cmfx/user/passport/otp/code/problems.go | 25 ++ cmfx/user/passport/otp/code/sender.go | 11 +- cmfx/user/passport/otp/totp/install.go | 2 +- cmfx/user/passport/otp/totp/models.go | 50 +++- cmfx/user/passport/otp/totp/models_test.go | 15 + cmfx/user/passport/otp/totp/problems.go | 29 ++ cmfx/user/passport/otp/totp/totp.go | 238 +++++++++------ cmfx/user/passport/otp/totp/totp_test.go | 91 +++--- cmfx/user/passport/passport.go | 107 ------- cmfx/user/passport/passport_test.go | 72 ----- cmfx/user/passport/password/install.go | 19 -- cmfx/user/passport/password/install_test.go | 24 -- cmfx/user/passport/password/models.go | 19 -- cmfx/user/passport/password/password.go | 166 ----------- cmfx/user/passport/password/password_test.go | 48 ---- cmfx/user/password.go | 186 ++++++++++++ cmfx/user/password_test.go | 27 ++ cmfx/user/securitylog.go | 6 +- cmfx/user/securitylog_test.go | 7 +- cmfx/user/token.go | 106 ++----- cmfx/user/token_test.go | 60 ++-- cmfx/user/usertest/usertest.go | 54 ++++ go.mod | 1 - go.sum | 2 - 60 files changed, 1468 insertions(+), 1681 deletions(-) create mode 100644 cmfx/initial/doc.go delete mode 100644 cmfx/modules/admin/route_auth.go create mode 100644 cmfx/user/passport.go delete mode 100644 cmfx/user/passport/adapter.go delete mode 100644 cmfx/user/passport/adaptertest/adaptertest.go delete mode 100644 cmfx/user/passport/errors.go delete mode 100644 cmfx/user/passport/oauth/install.go delete mode 100644 cmfx/user/passport/oauth/models.go delete mode 100644 cmfx/user/passport/oauth/oauth.go delete mode 100644 cmfx/user/passport/oauth/oauth_test.go delete mode 100644 cmfx/user/passport/oauth/twitter.go create mode 100644 cmfx/user/passport/otp/code/codetest/codetest.go create mode 100644 cmfx/user/passport/otp/code/problems.go create mode 100644 cmfx/user/passport/otp/totp/models_test.go create mode 100644 cmfx/user/passport/otp/totp/problems.go delete mode 100644 cmfx/user/passport/passport.go delete mode 100644 cmfx/user/passport/passport_test.go delete mode 100644 cmfx/user/passport/password/install.go delete mode 100644 cmfx/user/passport/password/install_test.go delete mode 100644 cmfx/user/passport/password/models.go delete mode 100644 cmfx/user/passport/password/password.go delete mode 100644 cmfx/user/passport/password/password_test.go create mode 100644 cmfx/user/password.go create mode 100644 cmfx/user/password_test.go create mode 100644 cmfx/user/usertest/usertest.go diff --git a/cmfx/cmfx.go b/cmfx/cmfx.go index 24c13166..e5c19569 100644 --- a/cmfx/cmfx.go +++ b/cmfx/cmfx.go @@ -38,7 +38,6 @@ const ( BadRequestInvalidBody = "40004" BadRequestBodyTooLarger = "40005" BadRequestBodyNotAllowed = "40006" // 提交内容的类型不允许,比如不允许的上传类型等 - BadRequestLastPassport = "40007" // 最后一个验证方式,不允许删除。 ) // 401 diff --git a/cmfx/initial/cmd/cmd.go b/cmfx/initial/cmd/cmd.go index 7b5f7964..86fa2100 100644 --- a/cmfx/initial/cmd/cmd.go +++ b/cmfx/initial/cmd/cmd.go @@ -11,10 +11,6 @@ import ( "github.com/issue9/upload/v3" "github.com/issue9/web" - "github.com/issue9/web/mimetype/cbor" - "github.com/issue9/web/mimetype/json" - "github.com/issue9/web/mimetype/yaml" - "github.com/issue9/web/openapi" "github.com/issue9/web/server" "github.com/issue9/web/server/app" "github.com/issue9/webuse/v7/handlers/debug" @@ -46,17 +42,7 @@ func initServer(name, ver string, o *server.Options, user *Config, action string } initial.Init(s, user.Ratelimit, web.PluginFunc(swagger.Install)) - - doc := openapi.New(s, web.Phrase("The api doc of %s", s.Name()), - openapi.WithMediaType(json.Mimetype, yaml.Mimetype, cbor.Mimetype), - openapi.WithResponse(&openapi.Response{ - Ref: &openapi.Ref{Ref: "empty"}, - Body: &openapi.Schema{Type: openapi.TypeObject}, - }), - openapi.WithProblemResponse(), - openapi.WithContact("caixw", "", "https://github.com/caixw"), - swagger.WithCDN(""), - ) + doc := initial.NewDocument(s) router := s.Routers().New("default", nil, web.WithAllowedCORS(3600), @@ -84,12 +70,12 @@ func initServer(name, ver string, o *server.Options, user *Config, action string switch action { case "serve": adminL := admin.Load(adminMod, user.Admin, uploadSaver) - adminL.Passport().Register(totp.New(adminL.Module(), "totp", web.Phrase("TOTP passport"))) + totp.Init(adminL.UserModule(), "totp", web.Phrase("TOTP passport")) system.Load(systemMod, user.System, adminL) case "install": adminL := admin.Install(adminMod, user.Admin) - totp.Install(adminL.Module(), "totp") + totp.Install(adminL.UserModule().Module(), "totp") system.Install(systemMod, user.System, adminL) case "upgrade": diff --git a/cmfx/initial/doc.go b/cmfx/initial/doc.go new file mode 100644 index 00000000..4423efe1 --- /dev/null +++ b/cmfx/initial/doc.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package initial + +import ( + "github.com/issue9/webuse/v7/plugins/openapi/swagger" + + "github.com/issue9/web" + "github.com/issue9/web/mimetype/cbor" + "github.com/issue9/web/mimetype/json" + "github.com/issue9/web/openapi" +) + +func NewDocument(s web.Server) *openapi.Document { + return openapi.New(s, web.Phrase("The api doc of %s", s.Name()), + openapi.WithMediaType(json.Mimetype, cbor.Mimetype), + openapi.WithResponse(&openapi.Response{ + Ref: &openapi.Ref{Ref: "empty"}, + Body: &openapi.Schema{Type: openapi.TypeObject}, + }), + openapi.WithProblemResponse(), + openapi.WithContact("caixw", "", "https://github.com/caixw"), + swagger.WithCDN(""), + ) +} diff --git a/cmfx/initial/initial.go b/cmfx/initial/initial.go index 3740b0e2..a5f2df67 100644 --- a/cmfx/initial/initial.go +++ b/cmfx/initial/initial.go @@ -61,7 +61,6 @@ func problems(s web.Server) { &web.LocaleProblem{ID: cmfx.BadRequestInvalidBody, Title: web.StringPhrase("bad request invalid body"), Detail: web.StringPhrase("bad request invalid body detail")}, &web.LocaleProblem{ID: cmfx.BadRequestBodyTooLarger, Title: web.StringPhrase("bad request body too Larger"), Detail: web.StringPhrase("bad request body too Larger detail")}, &web.LocaleProblem{ID: cmfx.BadRequestBodyNotAllowed, Title: web.StringPhrase("bad request body not allowed"), Detail: web.StringPhrase("bad request body not allowed detail")}, - &web.LocaleProblem{ID: cmfx.BadRequestLastPassport, Title: web.StringPhrase("bad request last passport"), Detail: web.StringPhrase("bad request last passport detail")}, ).Add(http.StatusUnauthorized, &web.LocaleProblem{ID: cmfx.UnauthorizedInvalidState, Title: web.StringPhrase("unauthorized invalid state"), Detail: web.StringPhrase("unauthorized invalid state detail")}, &web.LocaleProblem{ID: cmfx.UnauthorizedInvalidToken, Title: web.StringPhrase("unauthorized invalid token"), Detail: web.StringPhrase("unauthorized invalid token detail")}, diff --git a/cmfx/initial/test/suite.go b/cmfx/initial/test/suite.go index ca8750bb..53a0f2bd 100644 --- a/cmfx/initial/test/suite.go +++ b/cmfx/initial/test/suite.go @@ -5,6 +5,7 @@ package test import ( + "net/http" "os" "strings" @@ -13,10 +14,10 @@ import ( "github.com/issue9/orm/v6" "github.com/issue9/orm/v6/dialect" "github.com/issue9/web" - "github.com/issue9/web/openapi" "github.com/issue9/web/server/servertest" "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/initial" _ "github.com/mattn/go-sqlite3" ) @@ -36,7 +37,7 @@ func NewSuite(a *assert.Assertion) *Suite { a.NotError(err).NotNil(db) srv := NewServer(a) - doc := openapi.New(srv, web.Phrase("test")) + doc := initial.NewDocument(srv) s := &Suite{ a: a, dsn: dsn, @@ -87,6 +88,10 @@ func (s *Suite) Post(url string, body []byte) *rest.Request { return servertest.Post(s.Assertion(), buildURL(url), body) } +func (s *Suite) Put(url string, body []byte) *rest.Request { + return servertest.NewRequest(s.Assertion(),http.MethodPut, buildURL(url)).Body(body) +} + func (s *Suite) Get(url string) *rest.Request { return servertest.Get(s.a, buildURL(url)) } func buildURL(url string) string { diff --git a/cmfx/initial/test/test.go b/cmfx/initial/test/test.go index e4533fba..3f82b9ef 100644 --- a/cmfx/initial/test/test.go +++ b/cmfx/initial/test/test.go @@ -13,7 +13,9 @@ import ( "github.com/issue9/assert/v4" "github.com/issue9/logs/v7" "github.com/issue9/web" + "github.com/issue9/web/mimetype/cbor" "github.com/issue9/web/mimetype/json" + "github.com/issue9/web/mimetype/yaml" "github.com/issue9/web/server" "github.com/issue9/web/server/config" @@ -23,8 +25,11 @@ import ( // NewServer 创建 [web.Server] 实例 func NewServer(a *assert.Assertion) web.Server { srv, err := server.NewHTTP("test", "1.0.0", &server.Options{ - Logs: logs.New(logs.NewTermHandler(os.Stdout, nil), logs.WithLevels(logs.AllLevels()...), logs.WithCreated(logs.NanoLayout)), - Codec: web.NewCodec().AddMimetype(json.Mimetype, json.Marshal, json.Unmarshal, json.ProblemMimetype), + Logs: logs.New(logs.NewTermHandler(os.Stdout, nil), logs.WithLevels(logs.AllLevels()...), logs.WithCreated(logs.NanoLayout)), + Codec: web.NewCodec(). + AddMimetype(json.Mimetype, json.Marshal, json.Unmarshal, json.ProblemMimetype). + AddMimetype(yaml.Mimetype, yaml.Marshal, yaml.Unmarshal, yaml.ProblemMimetype). + AddMimetype(cbor.Mimetype, cbor.Marshal, cbor.Unmarshal, cbor.ProblemMimetype), HTTPServer: &http.Server{Addr: ":8080"}, }) a.NotError(err).NotNil(srv) diff --git a/cmfx/locales/und.yaml b/cmfx/locales/und.yaml index fc5517f6..0a15ae3a 100755 --- a/cmfx/locales/und.yaml +++ b/cmfx/locales/und.yaml @@ -1,9 +1,18 @@ languages: - und messages: + - key: TOTP passport + message: + msg: TOTP passport + - key: The ID of passport + message: + msg: The ID of passport - key: The api doc of %s message: msg: The api doc of %s + - key: The description of passport + message: + msg: The description of passport - key: add admin api message: msg: add admin api @@ -13,21 +22,6 @@ messages: - key: admin message: msg: admin - - key: admin login api - message: - msg: admin login api - - key: admin logout api - message: - msg: admin logout api - - key: admin refresh token api - message: - msg: admin refresh token api - - key: another password valid - message: - msg: another password valid - - key: auto register - message: - msg: auto register - key: backup api message: msg: backup api @@ -70,12 +64,15 @@ messages: - key: bad request invalid query detail message: msg: bad request invalid query detail - - key: bad request last passport + - key: before bind need create secret message: - msg: bad request last passport - - key: bad request last passport detail + msg: before bind need create secret + - key: before bind need create secret detail message: - msg: bad request last passport detail + msg: before bind need create secret detail + - key: bind %s passport for current user api + message: + msg: bind %s passport for current user api - key: can not do it for super message: msg: can not do it for super @@ -85,9 +82,24 @@ messages: - key: censor setting message: msg: censor setting + - key: change current user password for %s passport api + message: + msg: change current user password for %s passport api + - key: code + message: + msg: code + - key: code receiver, ignore when binded + message: + msg: code receiver, ignore when binded + - key: created time + message: + msg: created time - key: del backup database file message: msg: del backup database file + - key: delete %s passport for current user api + message: + msg: delete %s passport for current user api - key: delete admins message: msg: delete admins @@ -103,9 +115,6 @@ messages: - key: delete the admin api message: msg: delete the admin api - - key: different oauth state value %s %s - message: - msg: different oauth state value %s %s - key: edit role info api message: msg: edit role info api @@ -133,6 +142,12 @@ messages: - key: generator code for %s message: msg: generator code for %s + - key: get admin info api + message: + msg: get admin info api + - key: get admin list api + message: + msg: get admin list api - key: get admins message: msg: get admins @@ -151,9 +166,9 @@ messages: - key: get login user security log api message: msg: get login user security log api - - key: get passports api + - key: get passports list api message: - msg: get passports api + msg: get passports list api - key: get resources list api message: msg: get resources list api @@ -175,21 +190,27 @@ messages: - key: get system state api message: msg: get system state api - - key: identity already exists + - key: has been bind code + message: + msg: has been bind code + - key: has been bind code detail + message: + msg: has been bind code detail + - key: has been bind to other account message: - msg: identity already exists - - key: identity not exists + msg: has been bind to other account + - key: has been bind totp detail message: - msg: identity not exists + msg: has been bind totp detail + - key: has been totp bind + message: + msg: has been totp bind - key: identity registrable message: msg: identity registrable - key: identity registrable detail message: msg: identity registrable detail - - key: invalid identity format - message: - msg: invalid identity format - key: invalid url format message: msg: invalid url format @@ -217,12 +238,15 @@ messages: - key: log user agent message: msg: log user agent - - key: login + - key: login by %s + message: + msg: login by %s + - key: login by %s api message: - msg: login - - key: logout + msg: login by %s api + - key: logout api message: - msg: logout + msg: logout api - key: must be a dir message: msg: must be a dir @@ -235,21 +259,33 @@ messages: - key: must be less than %v message: msg: must be less than %v + - key: new password + message: + msg: new password + - key: not exists + message: + msg: not exists - key: not found message: msg: not found + - key: old password + message: + msg: old password - key: only for super message: msg: only for super - key: only for super detail message: msg: only for super detail - - key: passport adapter not found + - key: passport + message: + msg: passport + - key: passport password mode message: - msg: passport adapter not found - - key: password mode + msg: passport password mode + - key: password message: - msg: password mode + msg: password - key: patch admin info api message: msg: patch admin info api @@ -271,9 +307,18 @@ messages: - key: refresh token message: msg: refresh token - - key: reset admin password api + - key: refresh token api + message: + msg: refresh token api + - key: request code for %s passport bind api + message: + msg: request code for %s passport bind api + - key: request code for %s passport login api message: - msg: reset admin password api + msg: request code for %s passport login api + - key: request secret for %s passport api + message: + msg: request secret for %s passport api - key: role message: msg: role @@ -292,9 +337,18 @@ messages: - key: roles not exists message: msg: roles not exists - - key: smtp valid + - key: secret expired + message: + msg: secret expired + - key: secret expired detail + message: + msg: secret expired detail + - key: sex message: - msg: smtp valid + msg: sex + - key: state + message: + msg: state - key: strength invalid message: msg: strength invalid @@ -304,9 +358,9 @@ messages: - key: system message: msg: system - - key: test + - key: target message: - msg: test + msg: target - key: the ID of admin message: msg: the ID of admin @@ -316,24 +370,21 @@ messages: - key: the file name message: msg: the file name + - key: the new password can not be equal old + message: + msg: the new password can not be equal old - key: the role id message: msg: the role id - key: the value not in candidate message: msg: the value not in candidate - - key: uid already exists + - key: totp code message: - msg: uid already exists - - key: uid must be great than 0 + msg: totp code + - key: totp secret message: - msg: uid must be great than 0 - - key: uid not exists - message: - msg: uid not exists - - key: unauthorized - message: - msg: unauthorized + msg: totp secret - key: unauthorized invalid account message: msg: unauthorized invalid account @@ -379,12 +430,18 @@ messages: - key: user id message: msg: user id + - key: user logout + message: + msg: user logout - key: user no message: msg: user no - key: user state message: msg: user state + - key: username + message: + msg: username - key: view apis message: msg: view apis diff --git a/cmfx/locales/zh.yaml b/cmfx/locales/zh.yaml index f401352e..599a44e0 100644 --- a/cmfx/locales/zh.yaml +++ b/cmfx/locales/zh.yaml @@ -3,9 +3,18 @@ languages: - cmn-Hans - zh-CN messages: + - key: TOTP passport + message: + msg: TOTP 验证 + - key: The ID of passport + message: + msg: 登录验证器的 ID - key: The api doc of %s message: msg: '%s 的 API 文档' + - key: The description of passport + message: + msg: 登治验证器的描述 - key: add admin api message: msg: 添加管理员 @@ -15,21 +24,6 @@ messages: - key: admin message: msg: 管理员 - - key: admin login api - message: - msg: 管理员登录 - - key: admin logout api - message: - msg: 管理员退出 - - key: admin refresh token api - message: - msg: 刷新管理员令牌 - - key: another password valid - message: - msg: another password valid - - key: auto register - message: - msg: 自动注册 - key: backup api message: msg: 备份数据库 @@ -78,12 +72,15 @@ messages: message: msg: | 查询参数格式不正确,类似于无效的路径参数,大概率是前端提交的类型有误。 - - key: bad request last passport + - key: before bind need create secret + message: + msg: 绑定之前需要先创建密钥 + - key: before bind need create secret detail message: - msg: 只剩下一种认证方式了 - - key: bad request last passport detail + msg: 在使用绑定接口绑定 TOTP 之前,需要调用创建密钥的接口生成一密钥。 + - key: bind %s passport for current user api message: - msg: 最后一种认证方式是不能删除的。一个账号最起码保存一种认证方式,才能进行登录操作。 + msg: 为当前用户绑定 %s 验证方式 - key: can not do it for super message: msg: 不能对超级管理使用有此操作 @@ -93,9 +90,24 @@ messages: - key: censor setting message: msg: 内容审核设置 + - key: change current user password for %s passport api + message: + msg: 修改当前用户的 %s 验证方式的密码 + - key: code + message: + msg: 验证码 + - key: code receiver, ignore when binded + message: + msg: 验证码接收者,如果已经绑定,则会忽略此值 + - key: created time + message: + msg: 创建时间 - key: del backup database file message: msg: 删除备份的数据库文件 + - key: delete %s passport for current user api + message: + msg: 解绑当前用户与 %s 验证方式 - key: delete admins message: msg: 删除管理员 @@ -111,9 +123,6 @@ messages: - key: delete the admin api message: msg: 删除管理员 - - key: different oauth state value %s %s - message: - msg: 返回了不同的 oauth state 值 %s %s - key: edit role info api message: msg: 编辑角色信息 @@ -141,6 +150,12 @@ messages: - key: generator code for %s message: msg: 为 %s 生成验证码 + - key: get admin info api + message: + msg: 获取管理员的信息 + - key: get admin list api + message: + msg: 获取管理员列表 - key: get admins message: msg: 查看管理员信息 @@ -159,9 +174,9 @@ messages: - key: get login user security log api message: msg: 获取当前登录用户的安全日志列表 - - key: get passports api + - key: get passports list api message: - msg: 获取可用的登录方式列表 + msg: 获取支持验证方式列表 - key: get resources list api message: msg: 获取资源列表 @@ -183,12 +198,21 @@ messages: - key: get system state api message: msg: 查询系统状态 - - key: identity already exists + - key: has been bind code + message: + msg: 验证码验证方式已经绑定 + - key: has been bind code detail + message: + msg: 该方式的验证已经绑定,一般多次调用绑定接口可能会发生此错误 + - key: has been bind to other account message: - msg: 已经存在相同的 ID - - key: identity not exists + msg: 已经绑定到其它账号上 + - key: has been bind totp detail message: - msg: ID 不存在 + msg: 该账号已经绑定 TOTP + - key: has been totp bind + message: + msg: 该账号已经绑定 TOTP - key: identity registrable message: msg: | @@ -197,9 +221,6 @@ messages: message: msg: | 某些登录状态验证失败之后,会返回一个可用于注册的 ID,客户端可根据此 ID 注册新的账号。 - - key: invalid identity format - message: - msg: 无效的 ID 格式 - key: invalid url format message: msg: 无效的 URL 格式 @@ -227,12 +248,15 @@ messages: - key: log user agent message: msg: log user agent - - key: login + - key: login by %s message: - msg: 登录 - - key: logout + msg: 以 %s 的验证方式登录 + - key: login by %s api message: - msg: 退出 + msg: 以 %s 方式登录 + - key: logout api + message: + msg: 退出当前登录 - key: must be a dir message: msg: 必须得是个目录 @@ -245,21 +269,39 @@ messages: - key: must be less than %v message: msg: 须小于 %v + - key: new password + message: + msg: 新密码 + - key: not exists + message: + msg: 不存在 - key: not found message: msg: 未找到 + - key: old password + message: + msg: 旧密码 - key: only for super message: msg: 只有超级管理员才有此权限 - key: only for super detail message: msg: 某些权限只有超级管理员才拥有,比如转让超级管理员操作。 - - key: passport adapter not found + - key: passport + message: + msg: 验证方式 + - key: passport password mode message: - msg: 未找到指定的适配器 - - key: password mode + msg: 密码 + - key: password message: - msg: 密码登录 + msg: password + - key: patch admin info api + message: + msg: 更新管理员信息 + - key: patch login user info api + message: + msg: 更新登不用户个人信息 - key: post admins message: msg: 添加管理员 @@ -275,9 +317,18 @@ messages: - key: refresh token message: msg: 刷新令牌 - - key: reset admin password api + - key: refresh token api + message: + msg: 刷新令牌 + - key: request code for %s passport bind api + message: + msg: 为绑定 %s 验证方式请求验证码 + - key: request code for %s passport login api message: - msg: 重置管理员密码 + msg: 为 %s 验证方式登录请求验证码 + - key: request secret for %s passport api + message: + msg: 为 %s 验证方式请求密钥 - key: role message: msg: 角色 @@ -296,9 +347,18 @@ messages: - key: roles not exists message: msg: 角色不存在 - - key: smtp valid + - key: secret expired + message: + msg: TOTP 密钥过期 + - key: secret expired detail + message: + msg: TOTP 密钥过期 + - key: sex message: - msg: smtp valid + msg: 性别 + - key: state + message: + msg: 状态 - key: strength invalid message: msg: 密码强度不够 @@ -308,9 +368,9 @@ messages: - key: system message: msg: 系统 - - key: test + - key: target message: - msg: 测试 + msg: 接收者 - key: the ID of admin message: msg: 管理员 ID @@ -320,24 +380,21 @@ messages: - key: the file name message: msg: 文件名 + - key: the new password can not be equal old + message: + msg: 新旧密码不能相同 - key: the role id message: msg: 角色 ID - key: the value not in candidate message: msg: 该值并不在候选列表中 - - key: uid already exists + - key: totp code message: - msg: UID 已经存在 - - key: uid must be great than 0 + msg: TOTP 验证码 + - key: totp secret message: - msg: UID 必须大于零 - - key: uid not exists - message: - msg: UID 不存在 - - key: unauthorized - message: - msg: 账号或是密码错误 + msg: TOTP 密钥 - key: unauthorized invalid account message: msg: 账号或是密码错误 @@ -385,12 +442,18 @@ messages: - key: user id message: msg: user id + - key: user logout + message: + msg: 用户主动退出 - key: user no message: msg: user no - key: user state message: msg: user state + - key: username + message: + msg: 账号 - key: view apis message: msg: 查看接口信息 diff --git a/cmfx/modules/admin/admintest/admintest.go b/cmfx/modules/admin/admintest/admintest.go index 0b2df58a..90100acd 100644 --- a/cmfx/modules/admin/admintest/admintest.go +++ b/cmfx/modules/admin/admintest/admintest.go @@ -38,16 +38,16 @@ func NewModule(s *test.Suite) *admin.Module { } s.Assertion().NotError(o.SanitizeConfig()) - loader := admin.Install(mod, o) - s.Assertion().NotNil(loader) + m := admin.Install(mod, o) + s.Assertion().NotNil(m) - return loader + return m } // GetToken 获得后台的访问令牌 -func GetToken(s *test.Suite, loader *admin.Module) string { +func GetToken(s *test.Suite, m *admin.Module) string { r := &token.Response{} - s.Post(loader.URLPrefix()+"/login?type=password", []byte(`{"username":"admin","password":"123"}`)). + s.Post(m.URLPrefix()+"/passports/password/login", []byte(`{"username":"admin","password":"123"}`)). Header(header.ContentType, header.JSON+";charset=utf-8"). Header(header.Accept, header.JSON). Do(nil). diff --git a/cmfx/modules/admin/install.go b/cmfx/modules/admin/install.go index 359c9d31..d19b5cfe 100644 --- a/cmfx/modules/admin/install.go +++ b/cmfx/modules/admin/install.go @@ -5,21 +5,17 @@ package admin import ( - "time" - "github.com/issue9/upload/v3" "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/types" "github.com/issue9/cmfx/cmfx/user" - "github.com/issue9/cmfx/cmfx/user/passport/password" "github.com/issue9/cmfx/cmfx/user/rbac" ) func Install(mod *cmfx.Module, o *Config) *Module { user.Install(mod) - password.Install(mod, "passwords") rbac.Install(mod) if err := mod.DB().Create(&info{}); err != nil { @@ -89,7 +85,7 @@ func Install(mod *cmfx.Module, o *Config) *Module { } for _, u := range us { - if err := l.newAdmin(u, time.Now()); err != nil { + if err := l.newAdmin(u); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/modules/admin/models.go b/cmfx/modules/admin/models.go index 30959ba4..a6d91614 100644 --- a/cmfx/modules/admin/models.go +++ b/cmfx/modules/admin/models.go @@ -52,8 +52,8 @@ type ctxInfoWithRoleState struct { // 添加新的管理员时,需要提供的数据 type infoWithAccountDTO struct { ctxInfoWithRoleState - Username string `json:"username" xml:"username" cbor:"username"` // 账号 - Password string `json:"password" xml:"password" cbor:"password"` // 密码 + Username string `json:"username" xml:"username" cbor:"username" yaml:"username" comment:"username"` // 账号 + Password string `json:"password" xml:"password" cbor:"password" yaml:"password" comment:"password"` // 密码 } func (i *info) Filter(v *web.FilterContext) { diff --git a/cmfx/modules/admin/module.go b/cmfx/modules/admin/module.go index 2b71d25a..d3a9b7bb 100644 --- a/cmfx/modules/admin/module.go +++ b/cmfx/modules/admin/module.go @@ -7,7 +7,6 @@ package admin import ( "context" "errors" - "time" "github.com/issue9/events" "github.com/issue9/orm/v6" @@ -15,15 +14,11 @@ import ( "github.com/issue9/web" "github.com/issue9/web/openapi" "github.com/issue9/webuse/v7/handlers/static" - "github.com/issue9/webuse/v7/middlewares/acl/ratelimit" xrbac "github.com/issue9/webuse/v7/middlewares/acl/rbac" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/initial" "github.com/issue9/cmfx/cmfx/query" "github.com/issue9/cmfx/cmfx/user" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/password" "github.com/issue9/cmfx/cmfx/user/rbac" ) @@ -50,7 +45,6 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { u := user.Load(mod, o.User) - u.Passport().Register(password.New(mod, passportTypePassword, 8, web.StringPhrase("password mode"))) m := &Module{ user: u, @@ -83,16 +77,6 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { postAdmin := g.New("post-admin", web.StringPhrase("post admins")) delAdmin := g.New("del-admin", web.StringPhrase("delete admins")) - // 限制登录接口调用次数,可能存在 OPTIONS 等预检操作。 - loginRate := ratelimit.New(web.NewCache(mod.ID()+"_rate", mod.Server().Cache()), 20, time.Second, nil, nil) - - mod.Router().Prefix(m.URLPrefix()). - Get("/passports", m.getPassports). - Post("/passports/{type}/code/{identity}", m.postPassportCode, loginRate, initial.Unlimit(mod.Server())). - Post("/login", m.postLogin, loginRate, initial.Unlimit(mod.Server())). - Delete("/login", m.deleteLogin, m). - Put("/login", m.putToken, m) - mod.Router().Prefix(m.URLPrefix(), m). Get("/resources", m.getResources, mod.API(func(o *openapi.Operation) { o.Tag("admin", "rbac"). @@ -200,12 +184,6 @@ func Load(mod *cmfx.Module, o *Config, saver upload.Saver) *Module { ResponseRef("204", "empty", nil, nil) })) - // passport - mod.Router().Prefix(m.URLPrefix(), m). - Delete("/passports/{type}", m.deletePassport). - Post("/passports/{type}", m.postPassport). - Post("/passports/{type}/code", m.postCurrentPassportCode) - // upload up := upload.New(saver, o.Upload.Size, o.Upload.Exts...) mod.Router().Prefix(m.URLPrefix()). @@ -284,13 +262,11 @@ func (m *Module) OnLogin(f func(*user.User)) context.CancelFunc { return m.login // OnLogout 注册用户主动退出时的事 func (m *Module) OnLogout(f func(*user.User)) context.CancelFunc { return m.logoutEvent.Subscribe(f) } -func (m *Module) Module() *cmfx.Module { return m.user.Module() } - -func (m *Module) Passport() *passport.Passport { return m.user.Passport() } +func (m *Module) UserModule() *user.Module { return m.user } // 手动添加一个新的管理员 -func (m *Module) newAdmin(data *infoWithAccountDTO, now time.Time) error { - uid, err := m.user.NewUser(m.Passport().Get(passportTypePassword), data.Username, data.Password, now) +func (m *Module) newAdmin(data *infoWithAccountDTO) error { + uid, err := m.user.New(user.StateNormal, data.Username, data.Password) if err != nil { return err } @@ -302,7 +278,7 @@ func (m *Module) newAdmin(data *infoWithAccountDTO, now time.Time) error { Avatar: data.Avatar, Sex: data.Sex, } - if _, err = m.Module().DB().Insert(a); err != nil { + if _, err = m.UserModule().Module().DB().Insert(a); err != nil { return err } diff --git a/cmfx/modules/admin/route_admins.go b/cmfx/modules/admin/route_admins.go index 248f0b35..e1c27332 100644 --- a/cmfx/modules/admin/route_admins.go +++ b/cmfx/modules/admin/route_admins.go @@ -40,7 +40,7 @@ func (m *Module) getAdmin(ctx *web.Context) web.Responser { } a := &info{ID: id} - found, err := m.Module().DB().Select(a) + found, err := m.UserModule().Module().DB().Select(a) if err != nil { return ctx.Error(err, "") } @@ -59,10 +59,10 @@ func (m *Module) getAdmin(ctx *web.Context) web.Responser { } ps := make([]*respPassportIdentity, 0) - for k, v := range m.Passport().Identities(id) { + for k, v := range m.user.Identities(id) { ps = append(ps, &respPassportIdentity{ ID: k, - Username: v, + Identity: v, }) } slices.SortFunc(ps, func(a, b *respPassportIdentity) int { return cmp.Compare(a.ID, b.ID) }) // 排序,尽量使输出的内容相同 @@ -104,7 +104,7 @@ func (m *Module) getAdmins(ctx *web.Context) web.Responser { return resp } - sql := m.Module().DB().SQLBuilder().Select().Column("info.*").From(orm.TableName(&info{}), "info") + sql := m.UserModule().Module().DB().SQLBuilder().Select().Column("info.*").From(orm.TableName(&info{}), "info") if len(q.States) > 0 { m.user.LeftJoin(sql, "user", "{user}.{id}={info}.{id}", q.States) @@ -160,8 +160,8 @@ func (m *Module) patchAdmin(ctx *web.Context) web.Responser { } data.ID = u.ID // 指定主键 - err := m.Module().DB().DoTransaction(func(tx *orm.Tx) error { - e := tx.NewEngine(m.Module().DB().TablePrefix()) + err := m.UserModule().Module().DB().DoTransaction(func(tx *orm.Tx) error { + e := tx.NewEngine(m.UserModule().Module().DB().TablePrefix()) if _, err := e.Update(&data.info, "sex"); err != nil { return err } @@ -198,7 +198,7 @@ func (m *Module) postAdmins(ctx *web.Context) web.Responser { return resp } - if err := m.newAdmin(data, ctx.Begin()); err != nil { + if err := m.newAdmin(data); err != nil { return ctx.Error(err, "") } return web.Created(nil, "") @@ -237,3 +237,20 @@ func (m *Module) setAdminState(ctx *web.Context, state user.State, code int) web return web.Status(code) } + +func (m *Module) getUserFromPath(ctx *web.Context) (*user.User, web.Responser) { + id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) + if resp != nil { + return nil, resp + } + + u, err := m.user.GetUser(id) + if err != nil { + return nil, ctx.Error(err, "") + } + if u.State == user.StateDeleted { + return nil, ctx.Problem(cmfx.ForbiddenStateNotAllow) + } + + return u, nil +} diff --git a/cmfx/modules/admin/route_auth.go b/cmfx/modules/admin/route_auth.go deleted file mode 100644 index 73a8a2d4..00000000 --- a/cmfx/modules/admin/route_auth.go +++ /dev/null @@ -1,204 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package admin - -import ( - "cmp" - "errors" - "slices" - - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user" - "github.com/issue9/cmfx/cmfx/user/passport" -) - -// # API POST /login 管理员登录 -// @tag admin auth -// @req * github.com/issue9/cmfx/cmfx/user.Account -// @resp 201 * github.com/issue9/webuse/v7/middlewares/auth/token.Response -func (m *Module) postLogin(ctx *web.Context) web.Responser { - return m.user.Login(ctx, nil, func(u *user.User) { - m.loginEvent.Publish(false, u) - }) -} - -// # api delete /login 注销当前管理员的登录 -// @tag admin auth -// @resp 204 * {} -func (m *Module) deleteLogin(ctx *web.Context) web.Responser { - return m.user.Logout(ctx, func(u *user.User) { - m.logoutEvent.Publish(false, u) - }, web.StringPhrase("logout")) -} - -// # api put /login 续定令牌 -// @tag admin auth -// @resp 201 * github.com/issue9/webuse/v7/middlewares/auth/token.Response -func (m *Module) putToken(ctx *web.Context) web.Responser { - return m.user.RefreshToken(ctx) -} - -type respPassportAdapters struct { - ID string `json:"id" cbor:"id" xml:"id"` - Desc string `json:"desc" cbor:"desc" xml:"desc"` -} - -// # api GET /passports 支持的登录验证方式 -// @tag admin auth -// @resp 200 * respPassportAdapters -func (m *Module) getPassports(ctx *web.Context) web.Responser { - adapters := make([]*respPassportAdapters, 0) - for k, v := range m.Passport().All(ctx.LocalePrinter()) { - adapters = append(adapters, &respPassportAdapters{ - ID: k, - Desc: v, - }) - } - slices.SortFunc(adapters, func(a, b *respPassportAdapters) int { return cmp.Compare(a.ID, b.ID) }) - - return web.OK(adapters) -} - -// # api POST /passports/{type}/code/{identity} 请求新的验证码 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @path identity string 在 {type} 适配器中注册的用户标记,如果不存在将返回 404; -// @resp 201 * {} -func (m *Module) postPassportCode(ctx *web.Context) web.Responser { - typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) - if resp != nil { - return resp - } - - a := m.Passport().Get(typ) - if a == nil { - return ctx.Problem(cmfx.NotFound) - } - - identity, resp := ctx.PathString("identity", cmfx.BadRequestInvalidPath) - if resp != nil { - return resp - } - - uid, err := a.UID(identity) - if errors.Is(err, passport.ErrIdentityNotExists()) { - return ctx.Problem(cmfx.NotFound) - } else if err != nil { - return ctx.Error(err, "") - } - - if err := a.Update(uid); err != nil { - return ctx.Error(err, "") - } - return web.Created(nil, "") -} - -// # api POST /passports/{type}/code 请求新的验证码 -// @tag admin auth -// @path id id 管理员的 ID -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @resp 201 * {} -func (m *Module) postCurrentPassportCode(ctx *web.Context) web.Responser { - u, a, resp := m.getPassport(ctx) - if resp != nil { - return resp - } - - if err := a.Update(u.ID); err != nil { - return ctx.Error(err, "") - } - return web.Created(nil, "") -} - -// # api delete /passports/{type} 取消当前用户与登录方式 type 之间的关联 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @resp 204 * {} -func (m *Module) deletePassport(ctx *web.Context) web.Responser { - u, a, resp := m.getPassport(ctx) - if resp != nil { - return resp - } - - // 判断是否为最后个验证方式 - cnt := 0 - for range m.Passport().Identities(u.ID) { - cnt++ - if cnt > 1 { // 多余一个了,之后的就没必要了统计了。 - break - } - } - if cnt <= 1 { - return ctx.Problem(cmfx.BadRequestLastPassport) - } - - if err := a.Delete(u.ID); err != nil { - return ctx.Error(err, "") - } - return web.NoContent() -} - -type reqPassport struct { - Username string `json:"username" cbor:"username" xml:"username"` - Password string `json:"password" cbor:"password" xml:"password"` -} - -// # api POST /passports/{type} 建立当前用户与登录方式 type 之间的关联 -// @tag admin auth -// @path type string 登录的类型,该值必须是由 /passports 返回列表中的 id 值。 -// @req * reqPassport -// @resp 201 * {} -func (m *Module) postPassport(ctx *web.Context) web.Responser { - u, a, resp := m.getPassport(ctx) - if resp != nil { - return resp - } - - data := &reqPassport{} - if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { - return resp - } - - if err := a.Add(u.ID, data.Username, data.Password, ctx.Begin()); err != nil { - return ctx.Error(err, "") - } - - return web.Created(nil, "") -} - -func (m *Module) getPassport(ctx *web.Context) (*user.User, passport.Adapter, web.Responser) { - u := m.CurrentUser(ctx) - - typ, resp := ctx.PathString("type", cmfx.BadRequestInvalidPath) - if resp != nil { - return nil, nil, resp - } - - a := m.Passport().Get(typ) - if a == nil { - return nil, nil, ctx.Problem(cmfx.NotFound) - } - - return u, a, nil -} - -func (m *Module) getUserFromPath(ctx *web.Context) (*user.User, web.Responser) { - id, resp := ctx.PathID("id", cmfx.BadRequestInvalidPath) - if resp != nil { - return nil, resp - } - - u, err := m.user.GetUser(id) - if err != nil { - return nil, ctx.Error(err, "") - } - if u.State == user.StateDeleted { - return nil, ctx.Problem(cmfx.ForbiddenStateNotAllow) - } - - return u, nil -} diff --git a/cmfx/modules/admin/route_current.go b/cmfx/modules/admin/route_current.go index 4ad7e17e..b4c4b037 100644 --- a/cmfx/modules/admin/route_current.go +++ b/cmfx/modules/admin/route_current.go @@ -14,8 +14,8 @@ import ( ) type respPassportIdentity struct { - ID string `json:"id" xml:"id" cbor:"id"` - Username string `json:"username" xml:"username" cbor:"username"` + ID string `json:"id" xml:"id" cbor:"id" yaml:"id"` + Identity string `json:"identity" xml:"identity" cbor:"identity" yaml:"id"` } type respInfoWithPassport struct { @@ -29,7 +29,7 @@ type respInfoWithPassport struct { func (m *Module) getInfo(ctx *web.Context) web.Responser { u := m.CurrentUser(ctx) infomation := &info{ID: u.ID} - f, err := m.Module().DB().Select(infomation) + f, err := m.UserModule().Module().DB().Select(infomation) if err != nil { return ctx.Error(err, "") } @@ -38,10 +38,10 @@ func (m *Module) getInfo(ctx *web.Context) web.Responser { } ps := make([]*respPassportIdentity, 0) - for k, v := range m.Passport().Identities(u.ID) { + for k, v := range m.user.Identities(u.ID) { ps = append(ps, &respPassportIdentity{ ID: k, - Username: v, + Identity: v, }) } slices.SortFunc(ps, func(a, b *respPassportIdentity) int { return cmp.Compare(a.ID, b.ID) }) // 排序,尽量使输出的内容相同 @@ -61,7 +61,7 @@ func (m *Module) patchInfo(ctx *web.Context) web.Responser { a := m.CurrentUser(ctx) data.ID = a.ID // 确保 ID 正确 - _, err := m.Module().DB().Update(data) + _, err := m.UserModule().Module().DB().Update(data) if err != nil { return ctx.Error(err, "") } diff --git a/cmfx/user/config.go b/cmfx/user/config.go index 8b65a810..a369b764 100644 --- a/cmfx/user/config.go +++ b/cmfx/user/config.go @@ -6,6 +6,7 @@ package user import ( "strconv" + "time" "github.com/issue9/web" "github.com/issue9/web/server/config" @@ -25,6 +26,10 @@ type Config struct { // 刷新令牌的过期时间,单位为秒,如果为 0 则采用用 expires * 2 作为默认值。 RefreshExpired config.Duration `json:"refreshExpired,omitempty" xml:"refreshExpired,attr,omitempty" yaml:"refreshExpired,omitempty"` + + // 表示在登录或是注销之后的操作 + AfterLogin AfterFunc + AfterLogout AfterFunc } // SanitizeConfig 用于检测和修正配置项的内容 @@ -33,8 +38,12 @@ func (o *Config) SanitizeConfig() *web.FieldError { return web.NewFieldError("urlPrefix", locales.InvalidValue) } - if o.AccessExpired < 60 { - return web.NewFieldError("accessExpired", locales.MustBeGreaterThan(60)) + if o.AccessExpired == 0 { + o.AccessExpired = config.Duration(30 * time.Minute) + } + + if o.AccessExpired.Duration() < time.Minute { + return web.NewFieldError("accessExpired", locales.MustBeGreaterThan(time.Minute)) } if o.RefreshExpired == 0 { diff --git a/cmfx/user/config_test.go b/cmfx/user/config_test.go index 7ae7fa7e..d321c309 100644 --- a/cmfx/user/config_test.go +++ b/cmfx/user/config_test.go @@ -6,12 +6,14 @@ package user import ( "testing" + "time" "github.com/issue9/assert/v4" - "github.com/issue9/config" + xconf "github.com/issue9/config" + "github.com/issue9/web/server/config" ) -var _ config.Sanitizer = &Config{} +var _ xconf.Sanitizer = &Config{} func TestConfig_SanitizeConfig(t *testing.T) { a := assert.New(t, false) @@ -20,10 +22,11 @@ func TestConfig_SanitizeConfig(t *testing.T) { a.Equal(o.SanitizeConfig().Field, "urlPrefix") o = &Config{URLPrefix: "/admin"} - a.Equal(o.SanitizeConfig().Field, "accessExpired") + a.NotError(o.SanitizeConfig()). + Equal(o.AccessExpired, 30*time.Minute) - o = &Config{URLPrefix: "/admin", AccessExpired: 60} + o = &Config{URLPrefix: "/admin", AccessExpired: config.Duration(time.Hour)} a.NotError(o.SanitizeConfig()). - Equal(o.AccessExpired, 60). - Equal(o.RefreshExpired, 60*2) + Equal(o.AccessExpired, config.Duration(time.Hour)). + Equal(o.RefreshExpired, config.Duration(time.Hour)*2) } diff --git a/cmfx/user/install.go b/cmfx/user/install.go index 20903604..c2c38001 100644 --- a/cmfx/user/install.go +++ b/cmfx/user/install.go @@ -12,7 +12,7 @@ import ( // Install 安装当前的环境 func Install(mod *cmfx.Module) { - if err := mod.DB().Create(&User{}, &modelLog{}); err != nil { + if err := mod.DB().Create(&User{}, &logPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/user/models.go b/cmfx/user/models.go index e8360820..64d53bb6 100644 --- a/cmfx/user/models.go +++ b/cmfx/user/models.go @@ -19,29 +19,33 @@ const ( ) // State 表示管理员的状态 -// -// @enum -// @type string type State int8 func (s State) PrimitiveType() core.PrimitiveType { return core.String } // 安全日志 type LogVO struct { - Content string `json:"content" xml:",cdata" cbor:"content" comment:"log content"` - IP string `json:"ip" xml:"ip,attr" cbor:"ip" comment:"log IP"` - UserAgent string `json:"ua" xml:"ua" cbor:"ua" comment:"log user agent"` - Created time.Time `xml:"created" json:"created" cbor:"created" comment:"created time"` + Content string `json:"content" xml:",cdata" cbor:"content" yaml:"content" comment:"log content"` + IP string `json:"ip" xml:"ip,attr" cbor:"ip" yaml:"ip" comment:"log IP"` + UserAgent string `json:"ua" xml:"ua" cbor:"ua" yaml:"ua" comment:"log user agent"` + Created time.Time `xml:"created" json:"created" cbor:"created" yaml:"created" comment:"created time"` } //--------------------------------------- user --------------------------------------- type User struct { XMLName struct{} `orm:"-" json:"-" xml:"user" cbor:"-"` - ID int64 `orm:"name(id);ai" json:"id" xml:"id,attr" cbor:"id" comment:"user id"` // 用户的自增 ID - NO string `orm:"name(no);len(32);unique(no)" json:"no" xml:"no,attr" cbor:"no" comment:"user no"` // 用户的唯一编号,一般用于前端 - Created time.Time `orm:"name(created)" json:"created" xml:"created,attr" cbor:"created" comment:"created time"` // 添加时间 - State State `orm:"name(state)" json:"state" xml:"state,attr" cbor:"state" comment:"user state"` // 状态 + Created time.Time `orm:"name(created)" json:"created" xml:"created,attr" cbor:"created" yaml:"created" comment:"created time"` // 添加时间 + State State `orm:"name(state)" json:"state" xml:"state,attr" cbor:"state" yaml:"state" comment:"user state"` // 状态 + + // 用户的自增 ID + ID int64 `orm:"name(id);ai" json:"id" xml:"id,attr" cbor:"id" yaml:"id" comment:"user id"` + // 用户编号,唯一且无序。 + NO string `orm:"name(no);len(32);unique(no)" json:"no" xml:"no" cbor:"no" yaml:"no" comment:"user no"` + + // 登录信息,username 不唯一,保证在标记为删除的情况下,不影响相同值的数据添加。 + Username string `orm:"name(username);len(32)" json:"username,omitempty" yaml:"username,omitempty" xml:"username,omitempty" cbor:"username,omitempty" comment:"username"` + Password []byte `orm:"name(password);len(64)" json:"password,omitempty" yaml:"password,omitempty" xml:"password,omitempty" cbor:"password,omitempty" comment:"password"` } func (u *User) GetUID() string { return u.NO } @@ -56,7 +60,7 @@ func (u *User) BeforeInsert() error { //--------------------------------- modelLog --------------------------------------------- -type modelLog struct { +type logPO struct { ID int64 `orm:"name(id);ai"` Created time.Time `orm:"name(created)"` @@ -66,9 +70,9 @@ type modelLog struct { UserAgent string `orm:"name(user_agent);len(500)"` } -func (l *modelLog) TableName() string { return "_securitylogs" } +func (l *logPO) TableName() string { return "_securitylogs" } -func (l *modelLog) BeforeInsert() error { +func (l *logPO) BeforeInsert() error { l.Created = time.Now() l.Content = html.EscapeString(l.Content) l.IP = html.EscapeString(l.IP) @@ -77,4 +81,4 @@ func (l *modelLog) BeforeInsert() error { return nil } -func (l *modelLog) BeforeUpdate() error { panic("此表不存在更新记录的情况") } +func (l *logPO) BeforeUpdate() error { panic("此表不存在更新记录的情况") } diff --git a/cmfx/user/module.go b/cmfx/user/module.go index 517d8a98..d5b3a518 100644 --- a/cmfx/user/module.go +++ b/cmfx/user/module.go @@ -8,34 +8,64 @@ import ( "net/http" "github.com/issue9/cache" + "github.com/issue9/mux/v9/header" "github.com/issue9/orm/v6" "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/sliceutil" "github.com/issue9/web" + "github.com/issue9/web/openapi" "github.com/issue9/webuse/v7/middlewares/auth/token" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport" ) // Module 用户账号模块 type Module struct { - mod *cmfx.Module - urlPrefix string // 所有接口的 URL 前缀 - token *tokens - passport *passport.Passport + mod *cmfx.Module + urlPrefix string // 所有接口的 URL 前缀 + token *tokens + afterLogin AfterFunc + afterLogout AfterFunc + + passports []Passport } // Load 加载当前模块的环境 func Load(mod *cmfx.Module, conf *Config) *Module { store := token.NewCacheStore[*User](cache.Prefix(mod.Server().Cache(), mod.ID())) - return &Module{ - mod: mod, - urlPrefix: conf.URLPrefix, - token: token.New(mod.Server(), store, conf.AccessExpired.Duration(), conf.RefreshExpired.Duration(), web.ProblemUnauthorized, nil), - passport: passport.New(mod), + m := &Module{ + mod: mod, + urlPrefix: conf.URLPrefix, + token: token.New(mod.Server(), store, conf.AccessExpired.Duration(), conf.RefreshExpired.Duration(), web.ProblemUnauthorized, nil), + afterLogin: conf.AfterLogin, + afterLogout: conf.AfterLogout, + passports: make([]Passport, 0, 5), } + + mod.Router().Prefix(m.URLPrefix()). + Get("/passports", m.getPassports, mod.API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("get passports list api"), nil). + Response("200", []passportVO{}, nil, nil) + })) + + mod.Router().Prefix(m.URLPrefix(), m). + Delete("/token", m.logout, mod.API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("logout api"), nil). + Header(header.ClearSiteData, openapi.TypeString, nil, nil). + ResponseRef("204", "empty", nil, nil) + })). + Put("/token", m.refreshToken, mod.API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("refresh token api"), nil). + Response("204", &token.Response{}, nil, nil) + })) + + initPassword(m) + + return m } func (m *Module) URLPrefix() string { return m.urlPrefix } @@ -53,6 +83,22 @@ func (m *Module) GetUser(uid int64) (*User, error) { return u, nil } +// GetUserByUsername 根据账号名称查找用户对象 +// +// NOTE: 用户名在数据表中不具备唯一性,只能保证非删除的数据是唯一的。 +// 所以查找的数据不包含被标记为删除的数据。 +func (m *Module) GetUserByUsername(username string) (*User, error) { + sql := m.Module().DB().Where("username=? AND state<> ?", username, StateDeleted) + u := &User{} + if size, err := sql.Select(true, u); err != nil { + return nil, err + } else if size == 0 { + return nil, nil + } else { + return u, nil + } +} + // LeftJoin 将 [User.State] 以 LEFT JOIN 的形式插入到 sql 语句中 // // alias 为 [User] 表的别名,on 为 LEFT JOIN 的条件。 diff --git a/cmfx/user/module_test.go b/cmfx/user/module_test.go index f3e8fd43..e2e4a0bd 100644 --- a/cmfx/user/module_test.go +++ b/cmfx/user/module_test.go @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -package user +package user_test import ( "time" @@ -11,32 +11,31 @@ import ( "github.com/issue9/web/server/config" "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport/password" + "github.com/issue9/cmfx/cmfx/user" ) -var _ web.Middleware = &Module{} +var _ web.Middleware = &user.Module{} // 声明 [Module] 变量 // // 安装了注释库并提供一个 password 名称的密码验证功能 -func newModule(s *test.Suite) *Module { - conf := &Config{ +func NewModule(s *test.Suite, afterLogin, afterLogout user.AfterFunc) *user.Module { + conf := &user.Config{ URLPrefix: "/user", AccessExpired: 60 * config.Duration(time.Second), RefreshExpired: 600 * config.Duration(time.Second), + AfterLogin: afterLogin, + AfterLogout: afterLogout, } s.Assertion().NotError(conf.SanitizeConfig()) mod := s.NewModule("user") - Install(mod) - password.Install(mod, "password") + user.Install(mod) - u := Load(mod, conf) + u := user.Load(mod, conf) s.Assertion().NotNil(u) - u.Passport().Register(password.New(u.Module(), "password", 9, web.Phrase("password"))) - p := u.Passport().Get("password") - uid, err := u.NewUser(p, "admin", "password", time.Now()) + uid, err := u.New(user.StateNormal, "user", "123") s.Assertion().NotError(err).NotZero(uid) return u diff --git a/cmfx/user/passport.go b/cmfx/user/passport.go new file mode 100644 index 00000000..df536ae8 --- /dev/null +++ b/cmfx/user/passport.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package user + +import ( + "cmp" + "fmt" + "iter" + "slices" + + "github.com/issue9/web" +) + +// Passport 身份验证的适配器 +type Passport interface { + // ID 该适配器对象的唯一标记 + ID() string + + // Description 对当前实例的描述信息 + Description() web.LocaleStringer + + // Identity uid 在当前适配器中的 ID 名称 + Identity(uid int64) (identity string) + + // Delete 解绑用户 + Delete(uid int64) error +} + +func (m *Module) AddPassport(adp Passport) { + if slices.IndexFunc(m.passports, func(a Passport) bool { return a.ID() == adp.ID() }) >= 0 { + panic(fmt.Sprintf("已经存在同名 %s 的验证器", adp.ID())) + } + m.passports = append(m.passports, adp) +} + +type passportVO struct { + XMLName struct{} `json:"-" cbor:"-" yaml:"-" xml:"passports"` + ID string `json:"id" cbor:"id" xml:"id" yaml:"id" comment:"The ID of passport"` + Desc string `json:"desc" cbor:"desc" xml:"desc" yaml:"desc" comment:"The description of passport"` +} + +func (m *Module) getPassports(ctx *web.Context) web.Responser { + passports := make([]*passportVO, 0, len(m.passports)) + + for _, a := range m.passports { + passports = append(passports, &passportVO{ + ID: a.ID(), + Desc: a.Description().LocaleString(ctx.LocalePrinter()), + }) + } + slices.SortFunc(passports, func(a, b *passportVO) int { return cmp.Compare(a.ID, b.ID) }) + + return web.OK(passports) +} + +func (m *Module) getPassport(id string) Passport { + if index := slices.IndexFunc(m.passports, func(a Passport) bool { return a.ID() == id }); index >= 0 { + return m.passports[index] + } + return nil +} + +// 清空与 uid 相关的所有登录信息 +func (m *Module) deleteUser(uid int64) error { + for _, p := range m.passports { + if err := p.Delete(uid); err != nil { + return err + } + } + return nil +} + +// Identities 获取 uid 已经关联的适配器 +// +// 返回值键名为验证器 id,键值为该适配器对应的账号。 +func (p *Module) Identities(uid int64) iter.Seq2[string, string] { + return func(yield func(string, string) bool) { + for _, info := range p.passports { + if id := info.Identity(uid); id != "" { + if !yield(info.ID(), id) { + break + } + } + } + } +} diff --git a/cmfx/user/passport/adapter.go b/cmfx/user/passport/adapter.go deleted file mode 100644 index ef7db509..00000000 --- a/cmfx/user/passport/adapter.go +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-FileCopyrightText: 2024 caixw -// -// SPDX-License-Identifier: MIT - -package passport - -import ( - "time" - - "github.com/issue9/web" -) - -// Adapter 身份验证的适配器 -type Adapter interface { - // ID 该适配器对象的唯一标记 - ID() string - - // Description 对当前实例的描述信息 - Description() web.LocaleStringer - - // Valid 验证账号 - // - // username, password 向验证器提供的登录凭证,不同的实现对此两者的定义可能是不同的, - // 比如 oauth2 中表示的是由 authURL 返回的 state 和 code 参数。 - // - // uid 和 identity 分别表示验证成功之后,与之关联的用户 ID 以及在当前适配器中表示的唯一 ID。 - // 有可能存在 uid 为零而 identity 不会空的情况,比如由 [Adapter.Add] 添了一条 uid 为零的数据 - // 或是像 oauth 验证等也可能返回 uid 为零。一旦返回的 uid 为零,表示用户提交的数据没问题, - // 但是找不到与外部用户关联的 uid,可通过 [Adapter.Add] 与具体的 uid 进行关联; - // - // 如果验证失败,将返回 [ErrUnauthorized] 错误。 - Valid(username, password string, t time.Time) (uid int64, identity string, err error) - - // Identity 获取 uid 关联的账号名 - // - // 如果不存在,返回空值和 [ErrUIDNotExists] - Identity(int64) (string, error) - - // UID 获取与 identity 关联的 uid - // - // 如果不存在,返回空值和 [ErrIdentityNotExists] - // - // 如果返回 0,且不带错误信息,可能是临时验证的数据。 - UID(identity string) (int64, error) - - // Delete 解绑用户 - // - // 如果 uid 为零值,清空所有的临时验证数据。 - Delete(uid int64) error - - // Update 更新用户的验证数据 - // - // 部分实现可能不会实现该方法,比如基于固定时间算法的验证(TOTP), - // 或是以密码形式进行验证的接口。 - Update(uid int64) error - - // Add 关联用户数据 - // - // uid 表示关联的用户 ID,如果为空值,表示添加一个临时的验证数据, - // 之后在 [Adapter.Valid] 中验证不再返回错误,但是返回的 uid 为零; - // identity 为用户在当前对象中的唯一标记; - // code 为实现者的自定义行为,比如密码、设备的当前代码等。 - Add(uid int64, identity, code string, t time.Time) error -} diff --git a/cmfx/user/passport/adaptertest/adaptertest.go b/cmfx/user/passport/adaptertest/adaptertest.go deleted file mode 100644 index 1fb13c92..00000000 --- a/cmfx/user/passport/adaptertest/adaptertest.go +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -// adaptertest 提供了针对 [passport.Adapter] 的测试用例 -package adaptertest - -import ( - "time" - - "github.com/issue9/assert/v4" - - "github.com/issue9/cmfx/cmfx/user/passport" -) - -// RunBase 测试 p 的基本功能 -func RunBase(a *assert.Assertion, p passport.Adapter) { - // Add - - a.NotError(p.Add(1024, "1024", "1024", time.Now())) - a.ErrorIs(p.Add(1024, "1024", "1024", time.Now()), passport.ErrUIDExists()) - a.ErrorIs(p.Add(1000, "1024", "1024", time.Now()), passport.ErrIdentityExists()) - - a.NotError(p.Add(0, "2025", "2025", time.Now())) - a.NotError(p.Add(0, "2026", "2026", time.Now())) - a.ErrorIs(p.Add(111, "1024", "1024", time.Now()), passport.ErrIdentityExists()) // 1024 已经有 uid - a.NotError(p.Add(2025, "2025", "2025", time.Now())) // 将 "2025" 关联 uid - - // Valid - - uid, identity, err := p.Valid("1024", "1024", time.Now()) - a.NotError(err).Equal(identity, "1024").Equal(uid, 1024) - uid, identity, err = p.Valid("1024", "pass", time.Now()) // 密码错误 - a.Equal(err, passport.ErrUnauthorized()).Empty(identity).Equal(uid, 0) - uid, identity, err = p.Valid("not-exists", "pass", time.Now()) // 不存在 - a.Equal(err, passport.ErrUnauthorized()).Equal(identity, "").Equal(uid, 0) - - // Identity - - identity, err = p.Identity(1024) - a.NotError(err).Equal(identity, "1024") - identity, err = p.Identity(10240) - a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) - - // uid - - uid, err = p.UID("1024") - a.NotError(err).Equal(uid, 1024) - uid, err = p.UID("10240") - a.Equal(err, passport.ErrIdentityNotExists()).Zero(identity) - uid, err = p.UID("2025") - a.NotError(err).Zero(identity) - - // Delete - - a.NotError(p.Delete(1024)). - NotError(p.Delete(1024)) // 多次删除 - uid, identity, err = p.Valid("1024", "1025", time.Now()) - a.Equal(err, passport.ErrUnauthorized()).Equal(identity, "").Zero(uid) // 已删 - identity, err = p.Identity(1024) - a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) -} - -func RunUpdate(a *assert.Assertion, p passport.Adapter) { - a.ErrorIs(p.Update(1025), passport.ErrUIDNotExists()) - a.NotError(p.Update(1024)) -} diff --git a/cmfx/user/passport/errors.go b/cmfx/user/passport/errors.go deleted file mode 100644 index 0995d33f..00000000 --- a/cmfx/user/passport/errors.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2024 caixw -// -// SPDX-License-Identifier: MIT - -package passport - -import "github.com/issue9/web" - -var ( - errIdentityExists = web.NewLocaleError("identity already exists") - errUIDExists = web.NewLocaleError("uid already exists") - errIdentityNotExists = web.NewLocaleError("identity not exists") - errUIDNotExists = web.NewLocaleError("uid not exists") - errUnauthorized = web.NewLocaleError("unauthorized") - errInvalidIdentity = web.NewLocaleError("invalid identity format") - errAdapterNotFound = web.NewLocaleError("passport adapter not found") -) - -func ErrUIDMustBeGreatThanZero() error { return web.NewLocaleError("uid must be great than 0") } - -func ErrIdentityExists() error { return errIdentityExists } - -func ErrUIDExists() error { return errUIDExists } - -func ErrIdentityNotExists() error { return errIdentityNotExists } - -func ErrUIDNotExists() error { return errUIDNotExists } - -// ErrInvalidIdentity Identity 的格式错误 -func ErrInvalidIdentity() error { return errInvalidIdentity } - -func ErrUnauthorized() error { return errUnauthorized } - -func ErrAdapterNotFound() error { return errAdapterNotFound } diff --git a/cmfx/user/passport/oauth/install.go b/cmfx/user/passport/oauth/install.go deleted file mode 100644 index 9c8aaa77..00000000 --- a/cmfx/user/passport/oauth/install.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package oauth - -import ( - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport/utils" -) - -func Install(mod *cmfx.Module, tableName string) { - db := utils.BuildDB(mod, tableName) - if err := db.Create(&modelOAuth{}); err != nil { - panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) - } -} diff --git a/cmfx/user/passport/oauth/models.go b/cmfx/user/passport/oauth/models.go deleted file mode 100644 index d683b829..00000000 --- a/cmfx/user/passport/oauth/models.go +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package oauth - -import "time" - -type modelOAuth struct { - ID int64 `orm:"name(id);ai"` - Created time.Time `orm:"name(created)"` - UID int64 `orm:"name(uid);unique(uid)"` - Identity string `orm:"name(identity);len(32);unique(identity)"` -} - -func (p *modelOAuth) TableName() string { return `` } diff --git a/cmfx/user/passport/oauth/oauth.go b/cmfx/user/passport/oauth/oauth.go deleted file mode 100644 index fac68257..00000000 --- a/cmfx/user/passport/oauth/oauth.go +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -// Package oauth 提供基于 [OAuth2] 的登录和注册功能 -// -// ## 大致流程 -// 1. 前端携带 state 访问 authURL -// 1. 三方返回前端的 callback 页面 -// 1. callback 提交 {username: 'vendor id', password: 'code'} -// 1. 后端的登录页调用 Authenticator.Valid 验证验录,如果未注册则自动注册; -// 1. 登录页返回 token 给 callback 页,由该页面决定如何处理; -// -// [OAuth2]: https://oauth.net/2/ -package oauth - -import ( - "context" - "time" - - "github.com/issue9/orm/v6" - "github.com/issue9/web" - "golang.org/x/oauth2" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/utils" -) - -// UserInfo 表示 OAuth 登录后获取的用户信息 -type UserInfo interface { - // Identity 返回表示在服务器表示用户唯一 ID 的字符串 - Identity() string -} - -// GetUserInfoFunc 获取用户信息的方法 -// -// OAuth 并未规定返回的用户信息字段,该方法只能由用户根据平台自行实现。 -type GetUserInfoFunc[T UserInfo] func(*oauth2.Token) (T, error) - -// OAuth 表示 oauth2 登录的验证器 -type OAuth[T UserInfo] struct { - db *orm.DB - state string - config *oauth2.Config - f GetUserInfoFunc[T] - id string - desc web.LocaleStringer -} - -// New 声明 [OAuth] 对象 -// -// id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; -func New[T UserInfo](mod *cmfx.Module, id string, c *oauth2.Config, g GetUserInfoFunc[T], desc web.LocaleStringer) *OAuth[T] { - return &OAuth[T]{ - db: utils.BuildDB(mod, id), - state: mod.Server().UniqueID(), - config: c, - f: g, - id: id, - desc: desc, - } -} - -func (o *OAuth[T]) Description() web.LocaleStringer { return o.desc } - -func (o *OAuth[T]) ID() string { return o.id } - -// AuthURL 返回验证地址 -func (o *OAuth[T]) AuthURL() string { return o.config.AuthCodeURL(o.state) } - -// Valid 验证登录信息 -// -// state 为 oauth2 服务从 [OAuth.AuthURL] 返回的值; -func (o *OAuth[T]) Valid(state, code string, _ time.Time) (int64, string, error) { - if state != o.state { - return 0, "", web.NewLocaleError("different oauth state value %s %s", state, o.state) - } - - token, err := o.config.Exchange(context.Background(), code) - if err != nil { - return 0, "", err - } - - info, err := o.f(token) - if err != nil { - return 0, "", err - } - - mod := &modelOAuth{Identity: info.Identity()} - found, err := o.db.Select(mod) - if err != nil { - return 0, "", err - } - - if !found { - return 0, info.Identity(), nil - } - return mod.UID, info.Identity(), nil -} - -func (o *OAuth[T]) Delete(uid int64) error { - _, err := o.db.Where("uid=?", uid).Delete(&modelOAuth{}) // uid == 0 也是有效的值 - return err -} - -func (o *OAuth[T]) Identity(uid int64) (string, error) { - mod := &modelOAuth{UID: uid} - found, err := o.db.Select(mod) - if err != nil { - return "", err - } - if !found { - return "", passport.ErrUIDNotExists() - } - return mod.Identity, nil -} - -func (o *OAuth[T]) UID(identity string) (int64, error) { - mod := &modelOAuth{Identity: identity} - found, err := o.db.Select(mod) - if err != nil { - return 0, err - } - if !found { - return 0, passport.ErrUIDNotExists() - } - return mod.UID, nil -} - -func (o *OAuth[T]) Update(_ int64) error { return nil } - -func (o *OAuth[T]) Add(uid int64, identity, _ string, now time.Time) error { - _, err := o.db.Insert(&modelOAuth{ - Created: now, - UID: uid, - Identity: identity, - }) - return err -} diff --git a/cmfx/user/passport/oauth/oauth_test.go b/cmfx/user/passport/oauth/oauth_test.go deleted file mode 100644 index abd7d2f9..00000000 --- a/cmfx/user/passport/oauth/oauth_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package oauth - -import "github.com/issue9/cmfx/cmfx/user/passport" - -var _ passport.Adapter = &OAuth[*info]{} - -type info struct { - identity string -} - -func (i *info) Identity() string { return i.identity } diff --git a/cmfx/user/passport/oauth/twitter.go b/cmfx/user/passport/oauth/twitter.go deleted file mode 100644 index be77eea8..00000000 --- a/cmfx/user/passport/oauth/twitter.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package oauth - -import ( - "encoding/json" - "io" - "net/http" - - "github.com/issue9/mux/v9/header" - "golang.org/x/oauth2" -) - -const ( - TwitterAuthURL = "https://api.twitter.com/2/oauth2/authorize" - TwitterTokenURL = "https://api.twitter.com/2/oauth2/token" -) - -var TwitterScopes = []string{"tweet.read", "users.read"} - -type TwitterUserInfo struct { - ID string `json:"id"` - Name string `json:"name"` -} - -func (info *TwitterUserInfo) Identity() string { return info.ID } - -func TwitterGetUserInfo(token *oauth2.Token) (*TwitterUserInfo, error) { - const getUserInfoURL = "https://api.twitter.com/2/users/me" - - req, err := http.NewRequest(http.MethodGet, getUserInfoURL, nil) - if err != nil { - return nil, err - } - req.Header.Set(header.Authorization, token.AccessToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - info := &TwitterUserInfo{} - if err := json.Unmarshal(data, info); err != nil { - return nil, err - } - return info, nil -} diff --git a/cmfx/user/passport/otp/code/code.go b/cmfx/user/passport/otp/code/code.go index 3304b0b5..fad6e618 100644 --- a/cmfx/user/passport/otp/code/code.go +++ b/cmfx/user/passport/otp/code/code.go @@ -6,154 +6,262 @@ package code import ( + "errors" + "strconv" "time" + "github.com/issue9/cache" + "github.com/issue9/mux/v9/header" "github.com/issue9/orm/v6" "github.com/issue9/web" + "github.com/issue9/web/openapi" + "github.com/issue9/webuse/v7/middlewares/auth/token" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/locales" + "github.com/issue9/cmfx/cmfx/user" "github.com/issue9/cmfx/cmfx/user/passport/utils" ) type code struct { - db *orm.DB + db *orm.DB + cache web.Cache + sender Sender expired time.Duration + resend time.Duration gen Generator id string desc web.LocaleStringer + user *user.Module } // New 声明基于验证码的验证方法 // // id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; // expired 表示验证码的过期时间; -// tableName 用于指定验证码的表名,需要在同一个 mod 环境下是唯一的; -func New(mod *cmfx.Module, expired time.Duration, id string, gen Generator, sender Sender, desc web.LocaleStringer) passport.Adapter { +func Init(user *user.Module, expired, resend time.Duration, gen Generator, sender Sender, id string, desc web.LocaleStringer) user.Passport { + initProblems(user.Module().Server()) + if gen == nil { - gen = NumberGenerator(mod.Server(), id, 4) + gen = NumberGenerator(user.Module().Server(), id, 4) } - return &code{ - db: utils.BuildDB(mod, id), + c := &code{ + db: utils.BuildDB(user.Module(), id), + cache: web.NewCache(user.Module().ID()+"_passports_"+id+"_", user.Module().Server().Cache()), + sender: sender, expired: expired, + resend: resend, gen: gen, id: id, desc: desc, + user: user, } -} -func (e *code) ID() string { return e.id } + prefix := user.URLPrefix() + "/passports/" + id + user.Module().Router().Prefix(prefix). + Post("/login", c.postLogin, user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("login by %s api", id), nil). + Body(accountTO{}, false, nil, nil). + Response("201", token.Response{}, nil, nil) + })). + Post("/login/code", c.requestLoginCode, c.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("request code for %s passport login api", id), nil). + Response("201", TargetTO{}, nil, nil) + })) + user.Module().Router().Prefix(prefix, user). + Post("", c.bindCode, c.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("bind %s passport for current user api", id), nil). + Body(accountTO{}, false, nil, nil). + ResponseRef("201", "empty", nil, nil) + })). + Delete("", c.deleteTOTP, c.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("delete %s passport for current user api", id), nil). + ResponseRef("204", "empty", nil, nil) + })). + Post("/code", c.requestBindCode, c.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("request code for %s passport bind api", id), nil). + Response("201", TargetTO{}, nil, nil) + })) -func (e *code) Description() web.LocaleStringer { return e.desc } + user.AddPassport(c) -func (e *code) Delete(uid int64) error { - _, err := e.db.Where("uid=?", uid).Delete(&modelCode{}) // uid == 0 也是有效值 - return err + return c } -func (e *code) Valid(identity, code string, now time.Time) (int64, string, error) { - mod := &modelCode{Identity: identity} - found, err := e.db.Select(mod) - if err != nil { - return 0, "", err - } - - if !found || mod.Code != code || mod.Verified.Valid || mod.Expired.Before(now) { - return 0, "", passport.ErrUnauthorized() - } +// 已登录状态下请求绑定时发送的验证码 +func (e *code) requestBindCode(ctx *web.Context) web.Responser { + return e.requestCode(ctx, true) +} - return mod.UID, identity, nil +// 请求发送登录的验证码 +func (e *code) requestLoginCode(ctx *web.Context) web.Responser { + return e.requestCode(ctx, false) } -func (e *code) Identity(uid int64) (string, error) { - mod := &modelCode{} - size, err := e.db.Where("uid=?", uid).Select(true, mod) - if err != nil { - return "", err +func (e *code) requestCode(ctx *web.Context, isLogin bool) web.Responser { + data := &TargetTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp } - if size == 0 { - return "", passport.ErrUIDNotExists() + + code := &codePO{} + err := e.cache.Get(data.Target, code) + if err != nil && !errors.Is(err, cache.ErrCacheMiss()) { + return ctx.Error(err, "") + } + if err == nil && code.ReSend.After(ctx.Begin()) { // 存在且未过再次发送的时间点 + h := ctx.Header() + h.Set(header.XRateLimitLimit, "1") + h.Set(header.XRateLimitRemaining, "0") + h.Set(header.XRateLimitReset, strconv.FormatInt(code.ReSend.Unix(), 10)) + return ctx.Problem(web.ProblemTooManyRequests) } - return mod.Identity, nil -} -func (e *code) UID(identity string) (int64, error) { - mod := &modelCode{} - size, err := e.db.Where("identity=?", identity).Select(true, mod) - if err != nil { - return 0, err + if isLogin { // 登录状态需要检测是否已绑定到其它账号 + mod := &accountPO{Target: data.Target} + found, err := e.db.Select(mod) + switch { + case err != nil: + return ctx.Error(err, "") + case found && mod.UID > 0: + return ctx.Problem(web.ProblemBadRequest).WithParam("target", web.Phrase("has been bind to other account").LocaleString(ctx.LocalePrinter())) + } } - if size == 0 { - return 0, passport.ErrIdentityNotExists() + + v := e.gen() + go func() { + if err := e.sender.Sent(data.Target, v); err != nil { + e.user.Module().Server().Logs().ERROR().Error(err) + } + }() + + if err := e.cache.Set(data.Target, &codePO{Code: v, ReSend: ctx.Now().Add(e.resend)}, e.expired); err != nil { + return ctx.Error(err, "") } - return mod.UID, nil + return web.Created(nil, "") } -func (e *code) Update(uid int64) error { - if uid == 0 { - return passport.ErrUIDMustBeGreatThanZero() +func (e *code) bindCode(ctx *web.Context) web.Responser { + data := &accountTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + code := &codePO{} + err := e.cache.Get(data.Target, code) + switch { + case errors.Is(err, cache.ErrCacheMiss()): + return ctx.Problem(cmfx.BadRequestInvalidBody).WithParam("target", web.Phrase("not exists").LocaleString(ctx.LocalePrinter())) + case err != nil: + return ctx.Error(err, "") + case code.Code != data.Code: + return ctx.Problem(cmfx.BadRequestInvalidBody).WithParam("code", locales.InvalidValue.LocaleString(ctx.LocalePrinter())) } - m := e.getModel(uid) - if m == nil { - return passport.ErrUIDNotExists() + mod := &accountPO{Target: data.Target} + found, err := e.db.Select(mod) + switch { + case err != nil: + return ctx.Error(err, "") + case found: + return ctx.Problem(problemHasBind) } - code := e.gen() + mod = &accountPO{ + ID: mod.ID, + UID: e.user.CurrentUser(ctx).ID, + } + if _, _, err := e.db.Save(mod); err != nil { + return ctx.Error(err, "") + } - if _, err := e.db.Update(&modelCode{Identity: m.Identity, Code: code}, "code"); err != nil { - return err + if err := e.cache.Delete(data.Target); err != nil { + return ctx.Error(err, "") } - return e.sender.Sent(m.Identity, code) + return web.Created(nil, "") } -// Add 注册新用户 -// -// code 为验证码,可以用于验证,但是并不会真的发送该验证码。 -func (e *code) Add(uid int64, identity, code string, now time.Time) error { - if !e.sender.ValidIdentity(identity) { - return passport.ErrInvalidIdentity() +func (e *code) postLogin(ctx *web.Context) web.Responser { + data := &accountTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp } - if uid > 0 && e.getModel(uid) != nil { - return passport.ErrUIDExists() + code := &codePO{} + err := e.cache.Get(data.Target, code) + switch { + case errors.Is(err, cache.ErrCacheMiss()): + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) + case err != nil: + return ctx.Error(err, "") + case code.Code != data.Code: + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) } - mod := &modelCode{Identity: identity} + mod := &accountPO{Target: data.Target} found, err := e.db.Select(mod) if err != nil { - return err + return ctx.Error(err, "") } - if found { - if mod.UID > 0 { - return passport.ErrIdentityExists() + + if !found { // 未关联账号 + uid, err := e.user.New(user.StateNormal, data.Target, "") + if err != nil { + return ctx.Error(err, "") } + mod = &accountPO{ + Target: data.Target, + UID: uid, + } + if _, _, err := e.db.Save(mod); err != nil { + return ctx.Error(err, "") + } + } + + if err := e.cache.Delete(data.Target); err != nil { + return ctx.Error(err, "") + } + + u, err := e.user.GetUser(mod.UID) + if err != nil { + return ctx.Error(err, "") + } + return e.user.CreateToken(ctx, u, e) +} - _, err = e.db.Update(&modelCode{ - UID: uid, - Identity: identity, - Code: code, - }) - } else { - _, err = e.db.Insert(&modelCode{ - Created: now, - Expired: now.Add(e.expired), - Identity: identity, - UID: uid, - Code: code, - }) +func (e *code) deleteTOTP(ctx *web.Context) web.Responser { + if err := e.Delete(e.user.CurrentUser(ctx).ID); err != nil { + return ctx.Error(err, "") } + return web.NoContent() +} + +func (e *code) ID() string { return e.id } +func (e *code) Description() web.LocaleStringer { return e.desc } + +func (e *code) Delete(uid int64) error { + _, err := e.db.Where("uid=?", uid).Delete(&accountPO{}) // uid == 0 也是有效值 return err } -func (e *code) getModel(uid int64) *modelCode { - m := &modelCode{} - if f, err := e.db.Where("uid=?", uid).Select(true, m); err == nil && f > 0 { - return m +func (e *code) Identity(uid int64) string { + mod := &accountPO{UID: uid} + found, err := e.db.Select(mod) + if err != nil { + e.user.Module().Server().Logs().ERROR().Error(err) + return "" + } + if !found { + return "" } - return nil + return mod.Target } diff --git a/cmfx/user/passport/otp/code/code_test.go b/cmfx/user/passport/otp/code/code_test.go index e68734aa..195211fb 100644 --- a/cmfx/user/passport/otp/code/code_test.go +++ b/cmfx/user/passport/otp/code/code_test.go @@ -5,31 +5,78 @@ package code import ( + "net/http" "testing" "time" "github.com/issue9/assert/v4" + "github.com/issue9/mux/v9/header" "github.com/issue9/web" + "github.com/issue9/web/server/servertest" + "github.com/issue9/webuse/v7/middlewares/auth" "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/adaptertest" + "github.com/issue9/cmfx/cmfx/user" + "github.com/issue9/cmfx/cmfx/user/passport/otp/code/codetest" + "github.com/issue9/cmfx/cmfx/user/usertest" ) -var _ passport.Adapter = &code{} +var _ user.Passport = &code{} func TestCode(t *testing.T) { a := assert.New(t, false) + suite := test.NewSuite(a) defer suite.Close() - mod := suite.NewModule("test") - Install(mod, "codes") + sender := codetest.New() + + u := usertest.NewModule(suite) + Install(u.Module(), "code") + p := Init(u, time.Minute, time.Second, nil, sender, "code", web.Phrase("code")) + + defer servertest.Run(a, suite.Module().Server())() + defer suite.Close() + + u1, err := u.GetUserByUsername("u1") + a.NotError(err).NotNil(u1) + + identity := p.Identity(u1.ID) + a.Empty(identity) + + // 未注册,登录不了 + suite.Post("/user/passports/code/login", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Body([]byte(`{"target":"u1","code":"123"}`)). + Do(nil). + Status(http.StatusUnauthorized) + + tk := usertest.GetToken(suite, u) + + suite.Post("/user/passports/code/login/code", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Header(header.Authorization, auth.BuildToken(auth.Bearer, tk)). + Body([]byte(`{"target":"u2"}`)). + Do(nil). + Status(http.StatusCreated) + a.Equal(sender.Target, "u2").NotEmpty(sender.Code) - p := New(mod, 5*time.Minute, "codes", nil, &sender{}, web.Phrase("desc")) - a.NotNil(p) - adaptertest.RunBase(a, p) + // 登录操作,错误的账号和验证码 + suite.Post("/user/passports/code/login", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Header(header.Authorization, auth.BuildToken(auth.Bearer, tk)). + Body([]byte(`{"target":"u1","code":"` + sender.Code + `"}`)). + Do(nil). + Status(http.StatusUnauthorized) - p = New(mod, 5*time.Minute, "codes", nil, &sender{}, web.Phrase("desc")) - a.NotError(p.Add(1024, "1024", "1024", time.Now())) - adaptertest.RunUpdate(a, p) + // 正常登录操作 + suite.Post("/user/passports/code/login", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Header(header.Authorization, auth.BuildToken(auth.Bearer, tk)). + Body([]byte(`{"target":"u2","code":"` + sender.Code + `"}`)). + Do(nil). + Status(http.StatusCreated) } diff --git a/cmfx/user/passport/otp/code/codetest/codetest.go b/cmfx/user/passport/otp/code/codetest/codetest.go new file mode 100644 index 00000000..b29f6596 --- /dev/null +++ b/cmfx/user/passport/otp/code/codetest/codetest.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +// Package codetest 为 code 提供测试内容 +package codetest + +type EmptySender struct { + Target string + Code string +} + +// New 声明用于测试的 Sender +func New() *EmptySender { return &EmptySender{} } + +func (s *EmptySender) ValidIdentity(_ string) bool { return true } + +func (s *EmptySender) Sent(target, code string) error { + s.Target = target + s.Code = code + return nil +} diff --git a/cmfx/user/passport/otp/code/install.go b/cmfx/user/passport/otp/code/install.go index 0245d9da..ff62529e 100644 --- a/cmfx/user/passport/otp/code/install.go +++ b/cmfx/user/passport/otp/code/install.go @@ -13,7 +13,7 @@ import ( func Install(mod *cmfx.Module, tableName string) { db := utils.BuildDB(mod, tableName) - if err := db.Create(&modelCode{}); err != nil { + if err := db.Create(&accountPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/user/passport/otp/code/models.go b/cmfx/user/passport/otp/code/models.go index 8f7a18a6..228af49e 100644 --- a/cmfx/user/passport/otp/code/models.go +++ b/cmfx/user/passport/otp/code/models.go @@ -5,24 +5,53 @@ package code import ( - "database/sql" "time" + + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx/filters" ) -// 验证码的管理 -// // 一个用户一条记录,有新记录就执行覆盖操作, // 如果需要所有的验证码发送记录,需要使用者自己实现,比如短信发送记录,邮件的发送记录等。 -type modelCode struct { +type accountPO struct { // NOTE: 如果执行了删除操作,则物理删除记录。 - ID int64 `orm:"name(id);ai"` - Created time.Time `orm:"name(created)"` - Expired time.Time `orm:"name(expired)"` // 过期时间 - Verified sql.NullTime `orm:"name(verified);nullable"` // 验证时间 - Identity string `orm:"name(identity);len(500);unique(identity)"` // 接收者,手机号、邮箱等。 - Code string `orm:"name(code);len(8)"` // 验证码 - UID int64 `orm:"name(uid);default(0)"` // 关联的 UID,可以为空 + ID int64 `orm:"name(id);ai"` + Created time.Time `orm:"name(created)"` + Target string `orm:"name(target);len(500);unique(target)"` // 接收者,手机号、邮箱等。 + UID int64 `orm:"name(uid);unique(uid)"` // 关联的 UID +} + +func (l *accountPO) BeforeInsert() error { + l.Created = time.Now() + return nil +} + +func (l *accountPO) TableName() string { return `` } + +// 缓存系统中的验证码对象 +type codePO struct { + Code string + ReSend time.Time } -func (l *modelCode) TableName() string { return `` } +type accountTO struct { + XMLName struct{} `xml:"account" json:"-" cbor:"-" yaml:"-"` + Target string `json:"target" xml:"target" cbor:"target" yaml:"target" comment:"target"` + Code string `json:"code" xml:"code" cbor:"code" yaml:"code" comment:"code"` +} + +func (a *accountTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("code", &a.Code)). + Add(filters.NotEmpty("target", &a.Target)) +} + +type TargetTO struct { + XMLName struct{} `xml:"target" yaml:"-" json:"-" cbor:"-"` + Target string `json:"target" yaml:"target" cbor:"target" xml:"target" comment:"code receiver, ignore when binded"` +} + +func (a *TargetTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("target", &a.Target)) +} diff --git a/cmfx/user/passport/otp/code/models_test.go b/cmfx/user/passport/otp/code/models_test.go index e8b55933..24cdc13e 100644 --- a/cmfx/user/passport/otp/code/models_test.go +++ b/cmfx/user/passport/otp/code/models_test.go @@ -4,6 +4,14 @@ package code -import "github.com/issue9/orm/v6" +import ( + "github.com/issue9/orm/v6" + "github.com/issue9/web" +) -var _ orm.TableNamer = &modelCode{} +var ( + _ orm.TableNamer = &accountPO{} + _ orm.BeforeInserter = &accountPO{} + _ web.Filter = &accountTO{} + _ web.Filter = &TargetTO{} +) diff --git a/cmfx/user/passport/otp/code/problems.go b/cmfx/user/passport/otp/code/problems.go new file mode 100644 index 00000000..59a66950 --- /dev/null +++ b/cmfx/user/passport/otp/code/problems.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package code + +import ( + "net/http" + + "github.com/issue9/web" +) + +const ( + problemHasBind = "passports-code-hasBind" // 该账号已经绑定 +) + +func initProblems(s web.Server) { + if s.Problems().Exists(problemHasBind) { // 防止多次添加 + return + } + + s.Problems().Add(http.StatusConflict, + &web.LocaleProblem{ID: problemHasBind, Title: web.Phrase("has been bind code"), Detail: web.Phrase("has been bind code detail")}, + ) +} diff --git a/cmfx/user/passport/otp/code/sender.go b/cmfx/user/passport/otp/code/sender.go index ca6e97c1..30e49500 100644 --- a/cmfx/user/passport/otp/code/sender.go +++ b/cmfx/user/passport/otp/code/sender.go @@ -22,6 +22,8 @@ type Sender interface { // // target 为接收验证码的目标,比如邮箱地址或是手机号码等; // code 为发送的验证码; + // + // NOTE: 该方法会被异步调用。 Sent(target, code string) error } @@ -37,15 +39,6 @@ type smtpSender struct { auth smtp.Auth } -type emptySender struct{} - -// NewEmptySender 一个空的 [Sender] 实现 -func NewEmptySender() Sender { return &emptySender{} } - -func (s *emptySender) ValidIdentity(_ string) bool { return true } - -func (s *emptySender) Sent(_, _ string) error { return nil } - // NewSMTPSender 基于 SMTP 的 [Sender] 实现 // // subject 为发送邮件的主题; diff --git a/cmfx/user/passport/otp/totp/install.go b/cmfx/user/passport/otp/totp/install.go index dd5bdf67..d4dcb0db 100644 --- a/cmfx/user/passport/otp/totp/install.go +++ b/cmfx/user/passport/otp/totp/install.go @@ -13,7 +13,7 @@ import ( func Install(mod *cmfx.Module, tableName string) { db := utils.BuildDB(mod, tableName) - if err := db.Create(&modelTOTP{}); err != nil { + if err := db.Create(&accountPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/user/passport/otp/totp/models.go b/cmfx/user/passport/otp/totp/models.go index 11469ced..80151d4a 100644 --- a/cmfx/user/passport/otp/totp/models.go +++ b/cmfx/user/passport/otp/totp/models.go @@ -4,16 +4,48 @@ package totp -import "time" +import ( + "database/sql" + "time" -type modelTOTP struct { - ID int64 `orm:"name(id);ai"` - Created time.Time `orm:"name(created)"` - Updated time.Time `orm:"name(updated)"` + "github.com/issue9/web" - UID int64 `orm:"name(uid);default(0)"` - Identity string `orm:"name(identity);len(32);unique(identity)"` - Secret []byte `orm:"name(secret);len(160)"` + "github.com/issue9/cmfx/cmfx/filters" +) + +// 已开通 TOTP 的账号 +type accountPO struct { + ID int64 `orm:"name(id);ai"` + Requested time.Time `orm:"name(requested)"` // 请求绑定的时间 + Binded sql.NullTime `orm:"name(binded);nullable"` // 与用户绑定的时间 + UID int64 `orm:"name(uid);unique(uid)"` + Secret string `orm:"name(secret);len(32)"` +} + +func (p *accountPO) TableName() string { return `` } + +type accountTO struct { + XMLName struct{} `xml:"account" json:"-" cbor:"-" yaml:"-"` + Username string `json:"username" xml:"username" cbor:"username" yaml:"username" comment:"username"` + Code string `json:"code" xml:"code" cbor:"code" yaml:"code" comment:"totp code"` +} + +func (t *accountTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("code", &t.Code)). + Add(filters.NotEmpty("username", &t.Username)) } -func (p *modelTOTP) TableName() string { return `` } +type codeTO struct { + XMLName struct{} `xml:"code" json:"-" cbor:"-" yaml:"-"` + Code string `json:"code" xml:"code" cbor:"code" yaml:"code" comment:"totp code"` +} + +func (t *codeTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("code", &t.Code)) +} + +type secretVO struct { + XMLName struct{} `xml:"secret" yaml:"-" json:"-" cbor:"-"` + Username string `json:"username" yaml:"username" cbor:"username" xml:"username" comment:"username"` + Secret string `json:"secret" yaml:"secret" cbor:"secret" xml:"secret" comment:"totp secret"` +} diff --git a/cmfx/user/passport/otp/totp/models_test.go b/cmfx/user/passport/otp/totp/models_test.go new file mode 100644 index 00000000..044da952 --- /dev/null +++ b/cmfx/user/passport/otp/totp/models_test.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 caixw +// +// SPDX-License-Identifier: MIT + +package totp + +import ( + "github.com/issue9/orm/v6" + "github.com/issue9/web" +) + +var ( + _ web.Filter = &accountTO{} + _ orm.TableNamer = &accountPO{} +) diff --git a/cmfx/user/passport/otp/totp/problems.go b/cmfx/user/passport/otp/totp/problems.go new file mode 100644 index 00000000..a5edfaea --- /dev/null +++ b/cmfx/user/passport/otp/totp/problems.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +package totp + +import ( + "net/http" + + "github.com/issue9/web" +) + +const ( + problemHasBind = "passports-totp-hasBind" // 该账号已经绑定 + problemNeedSecret = "passports-totp-needSecret" // 需要先创建 secret + problemSecretExpired = "passports-totp-secretExpired" +) + +func initProblems(s web.Server) { + if s.Problems().Exists(problemHasBind) { // 防止多次添加 + return + } + + s.Problems().Add(http.StatusConflict, + &web.LocaleProblem{ID: problemHasBind, Title: web.Phrase("has been totp bind"), Detail: web.Phrase("has been bind totp detail")}, + &web.LocaleProblem{ID: problemNeedSecret, Title: web.Phrase("before bind need create secret"), Detail: web.Phrase("before bind need create secret detail")}, + &web.LocaleProblem{ID: problemSecretExpired, Title: web.Phrase("secret expired"), Detail: web.Phrase("secret expired detail")}, + ) +} diff --git a/cmfx/user/passport/otp/totp/totp.go b/cmfx/user/passport/otp/totp/totp.go index ac38c94d..05106902 100644 --- a/cmfx/user/passport/otp/totp/totp.go +++ b/cmfx/user/passport/otp/totp/totp.go @@ -10,6 +10,7 @@ package totp import ( "crypto/hmac" "crypto/sha1" + "database/sql" "encoding/binary" "math" "strconv" @@ -17,102 +18,198 @@ import ( "github.com/issue9/orm/v6" "github.com/issue9/web" + "github.com/issue9/web/openapi" + "github.com/issue9/webuse/v7/middlewares/auth/token" "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/locales" + "github.com/issue9/cmfx/cmfx/user" "github.com/issue9/cmfx/cmfx/user/passport/utils" ) +// 密钥未绑定时的过期时间 +const secretExpired = time.Hour + type totp struct { - mod *cmfx.Module + user *user.Module db *orm.DB id string desc web.LocaleStringer } -// New 声明基于 [TOTP] 的 [passport.Adapter] 实现 +// Init 向 user 注册 [TOTP] 的验证方式 // // [TOTP]: https://datatracker.ietf.org/doc/html/rfc6238 -func New(mod *cmfx.Module, id string, desc web.LocaleStringer) passport.Adapter { - db := utils.BuildDB(mod, id) - return &totp{ - mod: mod, - db: db, +func Init(user *user.Module, id string, desc web.LocaleStringer) user.Passport { + initProblems(user.Module().Server()) // 私有的错误码 + + p := &totp{ + user: user, + db: utils.BuildDB(user.Module(), id), id: id, desc: desc, } + + prefix := user.URLPrefix() + "/passports/" + id + + user.Module().Router().Post(prefix+"/login", p.login, user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("login by %s api", id), nil). + Body(accountTO{}, false, nil, nil). + Response("201", token.Response{}, nil, nil) + })) + + user.Module().Router().Prefix(prefix, user). + Post("", p.postBind, p.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("bind %s passport for current user api", id), nil). + Body(codeTO{}, false, nil, nil). + ResponseRef("201", "empty", nil, nil) + })). + Delete("", p.deleteTOTP, p.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("delete %s passport for current user api", id), nil). + ResponseRef("204", "empty", nil, nil) + })). + Post("/secret", p.postSecret, p.user.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("request secret for %s passport api"), nil). + Response("201", secretVO{}, nil, nil) + })) + + user.AddPassport(p) + + return p } func (p *totp) ID() string { return p.id } func (p *totp) Description() web.LocaleStringer { return p.desc } -// Add 添加账号 -// -// identity 表示账号,在登录页上,需要通过账号来判定验证码的关联对象。 -func (p *totp) Add(uid int64, identity, _ string, now time.Time) error { - n, err := p.db.Where("uid=?", uid).Count(&modelTOTP{}) - if err != nil { - return err - } - if uid > 0 && n > 0 { - return passport.ErrUIDExists() - } +func (p *totp) Delete(uid int64) error { + _, err := p.db.Delete(&accountPO{UID: uid}) + return err +} - mod := &modelTOTP{Identity: identity} - found, err := p.db.Select(mod) - if err != nil { - return err +func (p *totp) Identity(uid int64) string { + if u, err := p.user.GetUser(uid); err != nil { + p.user.Module().Server().Logs().ERROR().Error(err) + return "" + } else { + mod := &accountPO{UID: u.ID} + if found, err := p.db.Select(mod); err != nil { + p.user.Module().Server().Logs().ERROR().Error(err) + return "" + } else if !found { + return "" + } + return u.Username } +} - secret := []byte(p.mod.Server().UniqueID()) +// 请求绑定之前需要服务端创建一个密钥 +func (p *totp) postSecret(ctx *web.Context) web.Responser { + u := p.user.CurrentUser(ctx) - if found { - if mod.UID > 0 { // 存在同一个值的 - return passport.ErrIdentityExists() - } + mod := &accountPO{UID: u.ID} + found, err := p.db.Select(mod) + switch { + case err != nil: + return ctx.Error(err, "") + case found && mod.Binded.Valid: // 已经绑定 + return ctx.Problem(problemHasBind) + } - // NOTE: 存在 uid == 0 的临时验证数据 - _, err = p.db.Update(&modelTOTP{ - Updated: now, - UID: uid, - Identity: identity, - Secret: secret, - }) - } else { - _, err = p.db.Insert(&modelTOTP{ - Created: now, - Updated: now, - UID: uid, - Identity: identity, - Secret: secret, - }) + n := &accountPO{ + Requested: ctx.Begin(), // 每次都要更新请求时间,否则在绑定时可能会判定超时未绑定。 + UID: u.ID, + Secret: p.user.Module().Server().UniqueID(), + } + if _, _, err := p.db.Save(n); err != nil { + return ctx.Error(err, "") } - return err + return web.Created(&secretVO{Secret: n.Secret, Username: u.Username}, "") } -func (p *totp) Delete(uid int64) error { - _, err := p.db.Where("uid=?", uid).Delete(&modelTOTP{}) - return err +func (p *totp) deleteTOTP(ctx *web.Context) web.Responser { + if err := p.Delete(p.user.CurrentUser(ctx).ID); err != nil { + return ctx.Error(err, "") + } + return web.NoContent() } -func (p *totp) Update(uid int64) error { return nil } +// 执行登录操作 +func (p *totp) login(ctx *web.Context) web.Responser { + data := &accountTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + u, err := p.user.GetUserByUsername(data.Username) + if err != nil { + return ctx.Error(err, "") + } -func (p *totp) Valid(username, pass string, now time.Time) (int64, string, error) { - mod := &modelTOTP{Identity: username} + mod := &accountPO{UID: u.ID} found, err := p.db.Select(mod) if err != nil { - return 0, "", err + return ctx.Error(err, "") + } else if !found { // 未创建该类型的登录方式 + return ctx.Problem(cmfx.Unauthorized) + } + + if valid(data.Code, mod.Secret) { + return ctx.Problem(cmfx.Unauthorized) + } + return p.user.CreateToken(ctx, u, p) +} + +// 绑定 totp 码 +// +// 需要 /passports/xx/secret 作为前置 +func (p *totp) postBind(ctx *web.Context) web.Responser { + u := p.user.CurrentUser(ctx) + m := &accountPO{UID: u.ID} + found, err := p.db.Select(m) + switch { + case err != nil: + return ctx.Error(err, "") + case !found: + return ctx.Problem(problemNeedSecret) + case m.Binded.Valid: // 已关联用户 + return ctx.Problem(problemHasBind) + case m.Requested.Add(secretExpired).Before(ctx.Begin()): // 超时还未绑定 + if err := p.Delete(u.ID); err != nil { + p.user.Module().Server().Logs().ERROR().Error(err) + } + return ctx.Problem(problemSecretExpired) + } + + data := &codeTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp } - if !found { - return 0, "", passport.ErrUnauthorized() + if !valid(data.Code, m.Secret) { + return ctx.Problem(cmfx.BadRequestInvalidBody).WithParam("code", locales.InvalidValue.LocaleString(ctx.LocalePrinter())) } + mod := &accountPO{ + ID: m.ID, + UID: u.ID, + Binded: sql.NullTime{Time: ctx.Begin(), Valid: true}, + } + if _, err := p.db.Update(mod); err != nil { + return ctx.Error(err, "") + } + return web.Created(nil, "") +} + +func valid(code, secret string) bool { // 将时间戳转换为字节数组 msg := make([]byte, 8) - binary.BigEndian.PutUint64(msg, uint64(now.Unix()/30)) - h := hmac.New(sha1.New, mod.Secret) + binary.BigEndian.PutUint64(msg, uint64(time.Now().Unix()/30)) + h := hmac.New(sha1.New, []byte(secret)) h.Write(msg) hmacHash := h.Sum(nil) @@ -131,34 +228,5 @@ func (p *totp) Valid(username, pass string, now time.Time) (int64, string, error result = "0" + result } - if result == pass { - return mod.UID, mod.Identity, nil - } - return 0, "", passport.ErrUnauthorized() -} - -func (p *totp) Identity(uid int64) (string, error) { - mod := &modelTOTP{} - size, err := p.db.Where("uid=?", uid).Select(true, mod) - if err != nil { - return "", err - } - if size == 0 { - return "", passport.ErrUIDNotExists() - } - - return mod.Identity, nil -} - -func (p *totp) UID(identity string) (int64, error) { - mod := &modelTOTP{} - size, err := p.db.Where("identity=?", identity).Select(true, mod) - if err != nil { - return 0, err - } - if size == 0 { - return 0, passport.ErrIdentityNotExists() - } - - return mod.UID, nil + return result == code } diff --git a/cmfx/user/passport/otp/totp/totp_test.go b/cmfx/user/passport/otp/totp/totp_test.go index e37a787b..8db73021 100644 --- a/cmfx/user/passport/otp/totp/totp_test.go +++ b/cmfx/user/passport/otp/totp/totp_test.go @@ -5,61 +5,70 @@ package totp import ( + "encoding/json" + "net/http" "testing" - "time" "github.com/issue9/assert/v4" + "github.com/issue9/mux/v9/header" "github.com/issue9/web" + "github.com/issue9/web/server/servertest" + "github.com/issue9/webuse/v7/middlewares/auth" "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport" + "github.com/issue9/cmfx/cmfx/user" + "github.com/issue9/cmfx/cmfx/user/usertest" ) +var _ user.Passport = &totp{} + func TestTOTP(t *testing.T) { a := assert.New(t, false) suite := test.NewSuite(a) defer suite.Close() - mod := suite.NewModule("test") - Install(mod, "totp") - p := New(mod, "totp", web.Phrase("totp")) - - // Add - - a.NotError(p.Add(1024, "1024", "1024", time.Now())) - a.ErrorIs(p.Add(1024, "1024", "1024", time.Now()), passport.ErrUIDExists()) - a.ErrorIs(p.Add(1000, "1024", "1024", time.Now()), passport.ErrIdentityExists()) - - a.NotError(p.Add(0, "2025", "2025", time.Now())) - a.NotError(p.Add(0, "2026", "2026", time.Now())) - a.ErrorIs(p.Add(111, "1024", "1024", time.Now()), passport.ErrIdentityExists()) // 1024 已经有 uid - a.NotError(p.Add(2025, "2025", "2025", time.Now())) // 将 "2025" 关联 uid - - // Identity - identity, err := p.Identity(1024) - a.NotError(err).Equal(identity, "1024") - identity, err = p.Identity(10240) - a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) + u := usertest.NewModule(suite) + Install(u.Module(), "totp") + p := Init(u, "totp", web.Phrase("totp")) - // uid - - uid, err := p.UID("1024") - a.NotError(err).Equal(uid, 1024) - uid, err = p.UID("10240") - a.Equal(err, passport.ErrIdentityNotExists()).Zero(identity) - uid, err = p.UID("2025") - a.NotError(err).Zero(identity) - - // Delete - - a.NotError(p.Delete(1024)). - NotError(p.Delete(1024)) // 多次删除 - identity, err = p.Identity(1024) - a.Equal(err, passport.ErrUIDNotExists()).Empty(identity) - - // Update + defer servertest.Run(a, suite.Module().Server())() + defer suite.Close() - a.NotError(p.Update(1025)) - a.NotError(p.Update(1024)) + u1, err := u.GetUserByUsername("u1") + a.NotError(err).NotNil(u1) + + identity := p.Identity(u1.ID) + a.Empty(identity) + + // 未注册,登录不了 + suite.Post("/user/passports/totp/login", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Body([]byte(`{"username":"u1","code":"123"}`)). + Do(nil). + Status(http.StatusUnauthorized) + + tk := usertest.GetToken(suite, u) + + secret := &secretVO{} + suite.Post("/user/passports/totp/secret", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Header(header.Authorization, auth.BuildToken(auth.Bearer, tk)). + Do(nil). + Status(http.StatusCreated). + BodyFunc(func(a *assert.Assertion, body []byte) { + a.NotError(json.Unmarshal(body, secret)). + NotEmpty(secret.Secret) + }) + + // 进行关联,但验证码错误 + suite.Post("/user/passports/totp", nil). + Header(header.Accept, header.JSON). + Header(header.ContentType, header.JSON). + Header(header.Authorization, auth.BuildToken(auth.Bearer, tk)). + Body([]byte(`{"username":"u1","code":"123"}`)). + Do(nil). + Status(http.StatusBadRequest) } diff --git a/cmfx/user/passport/passport.go b/cmfx/user/passport/passport.go deleted file mode 100644 index f80279f8..00000000 --- a/cmfx/user/passport/passport.go +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -// Package passport 用户验证 -package passport - -import ( - "errors" - "fmt" - "iter" - "slices" - "time" - - "golang.org/x/text/message" - - "github.com/issue9/cmfx/cmfx" -) - -// Passport 验证器管理 -type Passport struct { - mod *cmfx.Module - adapters []Adapter -} - -// New 声明 [Passport] 对象 -func New(mod *cmfx.Module) *Passport { - return &Passport{ - mod: mod, - adapters: make([]Adapter, 0, 5), - } -} - -// Register 注册 [Adapter] -func (p *Passport) Register(adp Adapter) { - if slices.IndexFunc(p.adapters, func(a Adapter) bool { return a.ID() == adp.ID() }) >= 0 { - panic(fmt.Sprintf("已经存在同名 %s 的验证器", adp.ID())) - } - p.adapters = append(p.adapters, adp) -} - -// Get 返回注册的适配器 -// -// 如果找不到,则返回 nil。 -func (p *Passport) Get(id string) Adapter { - if index := slices.IndexFunc(p.adapters, func(a Adapter) bool { return a.ID() == id }); index >= 0 { - return p.adapters[index] - } - return nil -} - -// Valid 验证账号密码 -// -// id 表示通过 [Passport.Register] 注册适配器时的 id; -func (p *Passport) Valid(id, identity, password string, now time.Time) (int64, string, bool) { - if a := p.Get(id); a != nil { - uid, ident, err := a.Valid(identity, password, now) - switch { - case errors.Is(err, ErrUnauthorized()): - return 0, "", false - case err != nil: - p.mod.Server().Logs().ERROR().Error(err) - return 0, "", false - default: - return uid, ident, true - } - } - return 0, "", false -} - -// All 返回所有的适配器对象 -func (p *Passport) All(printer *message.Printer) iter.Seq2[string, string] { - return func(yield func(string, string) bool) { - for _, a := range p.adapters { - if !yield(a.ID(), a.Description().LocaleString(printer)) { - break - } - } - } -} - -// Identities 获取 uid 已经关联的适配器 -// -// 返回值键名为验证器 id,键值为该适配器对应的账号。 -func (p *Passport) Identities(uid int64) iter.Seq2[string, string] { - return func(yield func(string, string) bool) { - for _, info := range p.adapters { - if identity, err := info.Identity(uid); err == nil { - if !yield(info.ID(), identity) { - break - } - } else { - p.mod.Server().Logs().ERROR().Error(err) - } - } - } -} - -// DeleteUser 清空与 uid 相关的所有登录信息 -func (p *Passport) DeleteUser(uid int64) error { - for _, info := range p.adapters { - if err := info.Delete(uid); err != nil { - return err - } - } - return nil -} diff --git a/cmfx/user/passport/passport_test.go b/cmfx/user/passport/passport_test.go deleted file mode 100644 index b95f1df7..00000000 --- a/cmfx/user/passport/passport_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2024 caixw -// -// SPDX-License-Identifier: MIT - -package passport_test - -import ( - "maps" - "testing" - "time" - - "github.com/issue9/assert/v4" - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/password" -) - -func TestPassport(t *testing.T) { - a := assert.New(t, false) - suite := test.NewSuite(a) - mod1 := suite.NewModule("test_p1") - mod2 := suite.NewModule("test_p2") - - password.Install(mod1, "password1") - password.Install(mod2, "password2") - - p := passport.New(suite.Module()) - a.NotNil(p). - Length(maps.Collect(p.All(suite.Module().Server().Locale().Printer())), 0) - - // Register - - p1 := password.New(mod1, "password1", 5, web.Phrase("password1")) - p.Register(p1) - a.Equal(p.Get("password1"), p1) - - p2 := password.New(mod2, "password2", 5, web.Phrase("password2")) - p.Register(p2) - a.Equal(p.Get("password2"), p2) - - a.PanicString(func() { - p.Register(password.New(mod1, "password1", 5, web.Phrase("password"))) - }, "已经存在同名 password1 的验证器") - - a.Length(maps.Collect(p.All(suite.Module().Server().Locale().Printer())), 2) - - // Valid / Identities - - uid, identity, ok := p.Valid("password1", "1024", "1024", time.Now()) - a.False(ok).Equal(identity, "").Zero(uid) - a.Empty(maps.Collect(p.Identities(1024))) - - // p1.Add - a.NotError(p1.Add(1024, "1024", "1024", time.Now())) - uid, identity, ok = p.Valid("password1", "1024", "1024", time.Now()) - a.True(ok).Equal(identity, "1024").Equal(uid, 1024) - a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"password1": "1024"}) - - // p2.Add - a.NotError(p2.Add(1024, "1024", "1024", time.Now())) - uid, identity, ok = p.Valid("password2", "1024", "not match", time.Now()) - a.Zero(identity).Zero(uid).False(ok) - a.Equal(maps.Collect(p.Identities(1024)), map[string]string{"password1": "1024", "password2": "1024"}) - - // p.DeleteUser - - a.NotError(p.DeleteUser(1111)) // 不存在该用户 - a.NotError(p.DeleteUser(1024)) - a.Empty(maps.Collect(p.Identities(1024))) -} diff --git a/cmfx/user/passport/password/install.go b/cmfx/user/passport/password/install.go deleted file mode 100644 index 71633a96..00000000 --- a/cmfx/user/passport/password/install.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package password - -import ( - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport/utils" -) - -func Install(mod *cmfx.Module, tableName string) { - db := utils.BuildDB(mod, tableName) - if err := db.Create(&modelPassword{}); err != nil { - panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) - } -} diff --git a/cmfx/user/passport/password/install_test.go b/cmfx/user/passport/password/install_test.go deleted file mode 100644 index 9a3a134f..00000000 --- a/cmfx/user/passport/password/install_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package password - -import ( - "testing" - - "github.com/issue9/assert/v4" - - "github.com/issue9/cmfx/cmfx/initial/test" -) - -func TestInstall(t *testing.T) { - a := assert.New(t, false) - suite := test.NewSuite(a) - defer suite.Close() - - mod := suite.NewModule("test") - Install(mod, "passwords") - - suite.TableExists(mod.ID() + "_auth_passwords") -} diff --git a/cmfx/user/passport/password/models.go b/cmfx/user/passport/password/models.go deleted file mode 100644 index 9376d25a..00000000 --- a/cmfx/user/passport/password/models.go +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package password - -import "time" - -type modelPassword struct { - ID int64 `orm:"name(id);ai"` - Created time.Time `orm:"name(created)"` - Updated time.Time `orm:"name(updated)"` - - UID int64 `orm:"name(uid);default(0)"` - Identity string `orm:"name(identity);len(32);unique(identity)"` - Password []byte `orm:"name(password);len(64)"` -} - -func (p *modelPassword) TableName() string { return `` } diff --git a/cmfx/user/passport/password/password.go b/cmfx/user/passport/password/password.go deleted file mode 100644 index 10c937f5..00000000 --- a/cmfx/user/passport/password/password.go +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -// Package password 密码类型的验证器 -package password - -import ( - "errors" - "time" - - "github.com/issue9/orm/v6" - "github.com/issue9/web" - "golang.org/x/crypto/bcrypt" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/utils" -) - -type password struct { - db *orm.DB - cost int - id string - desc web.LocaleStringer -} - -// New 声明基于密码的验证方法 -// -// id 该适配器的唯一 ID,同时也作为表名的一部分,不应该包含特殊字符; -// cost 的值需介于 [bcrypt.MinCost,bcrypt.MaxCost] 之间,如果超出范围则会被设置为 [bcrypt.DefaultCost]。 -// 在同一个模块下需要用到多个密码验证的实例时,tableName 用于区别不同; -func New(mod *cmfx.Module, id string, cost int, desc web.LocaleStringer) passport.Adapter { - if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { - cost = bcrypt.DefaultCost - } - db := utils.BuildDB(mod, id) - return &password{db: db, cost: cost, id: id, desc: desc} -} - -func (p *password) ID() string { return p.id } - -func (p *password) Description() web.LocaleStringer { return p.desc } - -// Add 添加账号 -func (p *password) Add(uid int64, identity, pass string, now time.Time) error { - if !validIdentity(identity) { - return passport.ErrInvalidIdentity() - } - - n, err := p.db.Where("uid=?", uid).Count(&modelPassword{}) - if err != nil { - return err - } - if uid > 0 && n > 0 { - return passport.ErrUIDExists() - } - - mod := &modelPassword{Identity: identity} - found, err := p.db.Select(mod) - if err != nil { - return err - } - - pa, err := bcrypt.GenerateFromPassword([]byte(pass), p.cost) - if err != nil { - return err - } - - if found { - if mod.UID > 0 { - return passport.ErrIdentityExists() - } - - // NOTE: 存在 uid == 0 的临时验证数据 - _, err = p.db.Update(&modelPassword{ - Updated: now, - UID: uid, - Identity: identity, - Password: pa, - }) - } else { - _, err = p.db.Insert(&modelPassword{ - Created: now, - Updated: now, - UID: uid, - Identity: identity, - Password: pa, - }) - } - - return err -} - -// Delete 删除关联的密码信息 -func (p *password) Delete(uid int64) error { - _, err := p.db.Where("uid=?", uid).Delete(&modelPassword{}) - return err -} - -func (p *password) Update(uid int64) error { return nil } - -func (p *password) Valid(username, pass string, _ time.Time) (int64, string, error) { - mod := &modelPassword{Identity: username} - found, err := p.db.Select(mod) - if err != nil { - return 0, "", err - } - if !found { - return 0, "", passport.ErrUnauthorized() - } - - err = bcrypt.CompareHashAndPassword(mod.Password, []byte(pass)) - switch { - case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): - return 0, "", passport.ErrUnauthorized() - case err != nil: - return 0, "", err - default: - return mod.UID, mod.Identity, nil - } -} - -func (p *password) Identity(uid int64) (string, error) { - mod := &modelPassword{} - size, err := p.db.Where("uid=?", uid).Select(true, mod) - if err != nil { - return "", err - } - if size == 0 { - return "", passport.ErrUIDNotExists() - } - - return mod.Identity, nil -} - -func (p *password) UID(identity string) (int64, error) { - mod := &modelPassword{} - size, err := p.db.Where("identity=?", identity).Select(true, mod) - if err != nil { - return 0, err - } - if size == 0 { - return 0, passport.ErrIdentityNotExists() - } - - return mod.UID, nil -} - -func validIdentity(id string) bool { - if id == "" { - return false - } - - for _, r := range id { - if !isAlpha(r) && !isDigit(r) { - return false - } - } - - return true -} - -func isAlpha(r rune) bool { return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' } - -func isDigit(r rune) bool { return r >= '0' && r <= '9' } diff --git a/cmfx/user/passport/password/password_test.go b/cmfx/user/passport/password/password_test.go deleted file mode 100644 index 6ee39f5d..00000000 --- a/cmfx/user/passport/password/password_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw -// -// SPDX-License-Identifier: MIT - -package password - -import ( - "testing" - "time" - - "github.com/issue9/assert/v4" - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx/initial/test" - "github.com/issue9/cmfx/cmfx/user/passport" - "github.com/issue9/cmfx/cmfx/user/passport/adaptertest" -) - -var _ passport.Adapter = &password{} - -func TestPassword(t *testing.T) { - a := assert.New(t, false) - suite := test.NewSuite(a) - defer suite.Close() - mod := suite.NewModule("test") - Install(mod, "p") - - p := New(mod, "p", 11, web.Phrase("desc")) - a.NotNil(p) - - adaptertest.RunBase(a, p) -} - -func TestValidIdentity(t *testing.T) { - a := assert.New(t, false) - suite := test.NewSuite(a) - defer suite.Close() - mod := suite.NewModule("test") - Install(mod, "p") - - p := New(mod, "p", 11, web.Phrase("desc")) - a.NotNil(p) - - a.ErrorIs(p.Add(1024, "", "1024", time.Now()), passport.ErrInvalidIdentity()) - a.ErrorIs(p.Add(1024, "//", "1024", time.Now()), passport.ErrInvalidIdentity()) - a.NotError(p.Add(1024, "1024", "1024", time.Now())) - a.NotError(p.Add(1025, "abcd", "1024", time.Now())) -} diff --git a/cmfx/user/password.go b/cmfx/user/password.go new file mode 100644 index 00000000..57179acd --- /dev/null +++ b/cmfx/user/password.go @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "time" + + "github.com/issue9/web" + "github.com/issue9/web/filter" + "github.com/issue9/web/openapi" + "github.com/issue9/webuse/v7/middlewares/acl/ratelimit" + "github.com/issue9/webuse/v7/middlewares/auth/token" + "golang.org/x/crypto/bcrypt" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/filters" + "github.com/issue9/cmfx/cmfx/initial" +) + +const ( + defaultCost = bcrypt.DefaultCost + passwordMode = "password" +) + +type password struct { + mod *Module +} + +type accountDTO struct { + XMLName struct{} `xml:"account" json:"-" cbor:"-" yaml:"-"` + Username string `json:"username" xml:"username" cbor:"username" yaml:"username" comment:"username"` + Password string `json:"password" xml:"password" cbor:"password" yaml:"password" comment:"passport"` +} + +func (c *accountDTO) Filter(v *web.FilterContext) { + v.Add(filters.NotEmpty("username", &c.Username)). + Add(filters.NotEmpty("password", &c.Password)) +} + +func initPassword(mod *Module) { + p := &password{mod: mod} + router := mod.Module().Router().Prefix(mod.URLPrefix() + "/passports/" + passwordMode) + + rate := ratelimit.New(web.NewCache(mod.Module().ID()+"_rate", mod.Module().Server().Cache()), 20, time.Second, nil, nil) + router.Post("/login", p.postLogin, rate, initial.Unlimit(mod.Module().Server()), mod.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("login by %s api", passwordMode), nil). + Body(&accountDTO{}, false, nil, nil). + Response("201", token.Response{}, nil, nil) + })) + + router.Put("", p.putPassword, p.mod, p.mod.Module().API(func(o *openapi.Operation) { + o.Tag("auth"). + Desc(web.Phrase("change current user password for %s passport api"), nil). + Body(&passwordDTO{}, false, nil, nil). + ResponseRef("204", "empty", nil, nil) + })) + + mod.AddPassport(p) +} + +func (p *password) postLogin(ctx *web.Context) web.Responser { + data := &accountDTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidHeader); resp != nil { + return resp + } + + mod := &User{} + n, err := p.mod.mod.DB().Where("username=?", data.Username).Select(true, mod) + if err != nil { + return ctx.Error(err, "") + } + if n <= 0 { + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) + } + + // 如果密码是空值,则不能通过此方法登录 + if bcrypt.CompareHashAndPassword(mod.Password, []byte{}) == nil { + return ctx.Problem(cmfx.UnauthorizedNeedChangePassword) + } + + err = bcrypt.CompareHashAndPassword(mod.Password, []byte(data.Password)) + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) + case err != nil: + return ctx.Error(err, "") + default: + mod.Password = nil + return p.mod.CreateToken(ctx, mod, p) + } +} + +type passwordDTO struct { + XMLName struct{} `xml:"password" json:"-" yaml:"-" cbor:"-"` + New string `json:"new" yaml:"new" cbor:"new" comment:"new password"` + Old string `json:"old" yaml:"old" cbor:"old" comment:"old password"` +} + +func (a *passwordDTO) Filter(ctx *web.FilterContext) { + b := filter.NewBuilder(filter.V[string]( + func(t string) bool { return t == a.Old }, + web.Phrase("the new password can not be equal old"), + )) + + ctx.Add(filters.NotEmpty("new", &a.New)). + Add(filters.NotEmpty("old", &a.Old)). + Add(b("new", &a.New)) +} + +func (p *password) putPassword(ctx *web.Context) web.Responser { + data := &passwordDTO{} + if resp := ctx.Read(true, data, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + mod := &User{ID: p.mod.CurrentUser(ctx).ID} + + if found, err := p.mod.mod.DB().Select(mod); err != nil { + return ctx.Error(err, "") + } else if !found { + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) + } + + err := bcrypt.CompareHashAndPassword(mod.Password, []byte(data.Old)) + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return ctx.Problem(cmfx.UnauthorizedInvalidAccount) + case err != nil: + return ctx.Error(err, "") + } + + pa, err := bcrypt.GenerateFromPassword([]byte(data.New), defaultCost) + if err != nil { + return ctx.Error(err, "") + } + p.mod.mod.DB().Update(&User{ + ID: mod.ID, + Password: pa, + }) + + return web.NoContent() +} + +func (p *password) ID() string { return passwordMode } + +func (p *password) Description() web.LocaleStringer { return web.Phrase("passport password mode") } + +// Delete 删除关联的密码信息 +func (p *password) Delete(uid int64) error { return nil } + +func (p *password) Identity(uid int64) string { + mod := &User{ID: uid} + found, err := p.mod.mod.DB().Select(mod) + if err != nil { + p.mod.Module().Server().Logs().ERROR().Error(err) + return "" + } + if !found { + return "" + } + + return mod.Username +} + +// UsernameValidator 账号名的验证器 +func UsernameValidator(id string) bool { + if id == "" || isDigit(rune(id[0])) { + return false + } + + for _, r := range id { + if !isAlpha(r) && !isDigit(r) && r != '_' && r != '-' { + return false + } + } + + return true +} + +func isAlpha(r rune) bool { return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' } + +func isDigit(r rune) bool { return r >= '0' && r <= '9' } diff --git a/cmfx/user/password_test.go b/cmfx/user/password_test.go new file mode 100644 index 00000000..16588d1c --- /dev/null +++ b/cmfx/user/password_test.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2022-2024 caixw +// +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "github.com/issue9/assert/v4" + "github.com/issue9/web" +) + +var ( + _ Passport = &password{} + _ web.Filter = &passwordDTO{} + _ web.Filter = &accountDTO{} +) + +func TestUsernameValidator(t *testing.T) { + a := assert.New(t, false) + a.True(UsernameValidator("a123")). + True(UsernameValidator("_123")). + True(UsernameValidator("_1-23")). + False(UsernameValidator("1-23")). + False(UsernameValidator("")) +} diff --git a/cmfx/user/securitylog.go b/cmfx/user/securitylog.go index 1da4f303..d40646c8 100644 --- a/cmfx/user/securitylog.go +++ b/cmfx/user/securitylog.go @@ -18,7 +18,7 @@ import ( // // tx 如果为空,表示由 AddSecurityLog 直接提交数据; func (m *Module) AddSecurityLog(tx *orm.Tx, uid int64, ip, ua, content string) error { - _, err := m.Module().Engine(tx).Insert(&modelLog{ + _, err := m.Module().Engine(tx).Insert(&logPO{ UID: uid, Content: content, IP: ip, @@ -50,7 +50,7 @@ func (m *Module) getSecurityLogs(uid int64, ctx *web.Context) web.Responser { return rslt } - sql := m.mod.DB().SQLBuilder().Select().Columns("*").From(orm.TableName(&modelLog{})). + sql := m.mod.DB().SQLBuilder().Select().Columns("*").From(orm.TableName(&logPO{})). Desc("created"). Where("uid=?", uid) if q.Text.Text != "" { @@ -64,7 +64,7 @@ func (m *Module) getSecurityLogs(uid int64, ctx *web.Context) web.Responser { sql.And("{end} Date: Sun, 1 Dec 2024 10:36:17 +0800 Subject: [PATCH 09/11] =?UTF-8?q?refactor(admin):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=87=B3=E6=96=B0=E7=9A=84=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/app/context/context.tsx | 13 ++-- admin/src/core/api/api.spec.ts | 6 +- admin/src/core/api/api.ts | 30 ++++--- admin/src/core/api/types.ts | 9 --- admin/src/messages/cmn-Hans.ts | 1 + admin/src/messages/en.ts | 1 + admin/src/pages/current/login.tsx | 116 +++++++++++++++++++++------- admin/src/pages/current/profile.tsx | 4 +- admin/src/pages/current/style.css | 16 +++- 9 files changed, 132 insertions(+), 64 deletions(-) diff --git a/admin/src/app/context/context.tsx b/admin/src/app/context/context.tsx index e4b8c885..95889a94 100644 --- a/admin/src/app/context/context.tsx +++ b/admin/src/app/context/context.tsx @@ -7,7 +7,8 @@ import { JSX, createContext, createResource, createSignal, useContext } from 'so import { Options as buildOptions } from '@/app/options'; import { NotifyType } from '@/components/notify'; -import { API, Account, Config, Locale, Method, Problem, Theme, UnitStyle, notify } from '@/core'; +import { API, Config, Locale, Method, Problem, Return, Theme, UnitStyle, notify } from '@/core'; +import { Token } from '@/core/api/token'; import { User } from './user'; type Options = Required; @@ -178,16 +179,16 @@ export function buildContext(opt: Required, f: API) { }, /** - * 执行登录操作并刷新 user + * 设置登录状态并刷新 user * @param account 账号密码信息 * @returns true 表示登录成功,其它情况表示错误信息 */ - async login(account: Account) { - const ret = await f.login(account); + async login(r: Return) { + const ret = await f.login(r); if (ret === true) { - uid = account.username; - sessionStorage.setItem(currentKey, uid); await userData.refetch(); + uid = this.user()!.id!.toString(); + sessionStorage.setItem(currentKey, uid); await localeData.refetch(); } return ret; diff --git a/admin/src/core/api/api.spec.ts b/admin/src/core/api/api.spec.ts index d7088274..72bc7951 100644 --- a/admin/src/core/api/api.spec.ts +++ b/admin/src/core/api/api.spec.ts @@ -82,7 +82,11 @@ describe('API token', () => { test('login', async () => { const f = await API.build('http://localhost', '/login', 'application/json', 'zh-cn'); fetchMock.mockResponseOnce(JSON.stringify(Object.assign({}, token))); - const ret = await f.login({ username: 'admin', password: '123' }, 'password'); + const ret = await f.login({ + status: 201, + ok: true, + body: { access_token: 'access', refresh_token: 'refresh', access_exp: 12345, refresh_exp: 12345 }, + }); expect(ret).toBeTruthy(); let t = await f.getToken(); diff --git a/admin/src/core/api/api.ts b/admin/src/core/api/api.ts index fe2a3613..93ece5e4 100644 --- a/admin/src/core/api/api.ts +++ b/admin/src/core/api/api.ts @@ -6,14 +6,14 @@ import { CacheImplement } from './cache'; import type { Mimetype, Serializer } from './serializer'; import { serializers } from './serializer'; import { delToken, getToken, state, Token, TokenState, writeToken } from './token'; -import { Account, Method, Problem, Query, Return } from './types'; +import { Method, Problem, Query, Return } from './types'; /** * 封装了 API 访问的基本功能 */ export class API { readonly #baseURL: string; - readonly #loginPath: string; + readonly #tokenPath: string; #locale: string; #token: Token | undefined; @@ -28,10 +28,10 @@ export class API { * * @param baseURL API 的基地址,不能以 / 结尾。 * @param mimetype mimetype 的类型。 - * @param loginPath 相对于 baseURL 的登录地址,该地址应该包含 POST、DELETE 和 PUT 三个请求,分别代表登录、退出和刷新令牌。 + * @param tokenPath 相对于 baseURL 的登录地址,该地址应该包含 DELETE 和 PUT 三个请求,分别代表退出和刷新令牌。 * @param locale 请求报头 accept-language 的内容。 */ - static async build(baseURL: string, loginPath: string, mimetype: Mimetype, locale: string): Promise { + static async build(baseURL: string, tokenPath: string, mimetype: Mimetype, locale: string): Promise { const t = getToken(); let c: Cache; @@ -41,17 +41,17 @@ export class API { console.warn('非 HTTP 环境,无法启用 API 缓存功能!'); c = new CacheImplement(); } - return new API(baseURL, loginPath, mimetype, locale, c, t); + return new API(baseURL, tokenPath, mimetype, locale, c, t); } - private constructor(baseURL: string, loginPath: string, mimetype: Mimetype, locale: string, cache: Cache, token: Token | undefined) { + private constructor(baseURL: string, tokenPath: string, mimetype: Mimetype, locale: string, cache: Cache, token: Token | undefined) { const s = serializers.get(mimetype); if (!s) { throw `不支持的 contentType ${mimetype}`; } this.#baseURL = baseURL; - this.#loginPath = loginPath; + this.#tokenPath = tokenPath; this.#locale = locale; this.#token = token; @@ -197,26 +197,24 @@ export class API { } /** - * 执行登录操作 + * 设置登录状态 * * @returns 如果返回 true,表示操作成功,否则表示错误信息。 */ - async login(account: Account): Promise|undefined|true> { - const token = await this.post(this.#loginPath, account, false); - if (token.ok) { - this.#token = writeToken(token.body!); + async login(ret: Return): Promise|undefined|true> { + if (ret.ok) { + this.#token = writeToken(ret.body!); await this.clearCache(); return true; } - - return token.body; + return ret.body; } /** * 退出当前的登录状态 */ async logout() { - await this.delete(this.#loginPath); + await this.delete(this.#tokenPath); this.#token = undefined; delToken(); await this.clearCache(); @@ -246,7 +244,7 @@ export class API { return undefined; case TokenState.AccessExpired: // 尝试刷新令牌 { //大括号的作用是防止 case 内部的变量 ret 提升作用域! - const ret = await this.withArgument(this.#loginPath, 'PUT', this.#token.refresh_token, this.#contentType); + const ret = await this.withArgument(this.#tokenPath, 'PUT', this.#token.refresh_token, this.#contentType); if (!ret.ok) { return undefined; } diff --git a/admin/src/core/api/types.ts b/admin/src/core/api/types.ts index a7fcaad3..fbf49490 100644 --- a/admin/src/core/api/types.ts +++ b/admin/src/core/api/types.ts @@ -68,15 +68,6 @@ export type Return = { ok: true; }; -/** - * 登录接口的需要用户提供的对象 - */ -export interface Account { - type: string; - username: string; - password: string; -} - /** * 分页接口返回的对象 */ diff --git a/admin/src/messages/cmn-Hans.ts b/admin/src/messages/cmn-Hans.ts index 5d7f7e45..7f0f9854 100644 --- a/admin/src/messages/cmn-Hans.ts +++ b/admin/src/messages/cmn-Hans.ts @@ -46,6 +46,7 @@ const messages: Messages = { login: '登录', username: '账号', password: '密码', + code: '验证码', loggingOut: '正在退出...', securitylog: '安全日志', diff --git a/admin/src/messages/en.ts b/admin/src/messages/en.ts index acbb049b..354d8590 100644 --- a/admin/src/messages/en.ts +++ b/admin/src/messages/en.ts @@ -44,6 +44,7 @@ const messages = { login: 'login', username: 'username', password: 'password', + code: 'code', loggingOut: 'logging out ...', securitylog: 'security logs', diff --git a/admin/src/pages/current/login.tsx b/admin/src/pages/current/login.tsx index 837f6797..d3d3d714 100644 --- a/admin/src/pages/current/login.tsx +++ b/admin/src/pages/current/login.tsx @@ -6,8 +6,7 @@ import { Navigate, useNavigate } from '@solidjs/router'; import { createSignal, For, JSX, Match, onMount, Show, Switch } from 'solid-js'; import { useApp, useOptions } from '@/app/context'; -import { buildEnumsOptions, Button, Choice, Icon, ObjectAccessor, Page, Password, TextField } from '@/components'; -import { Account } from '@/core'; +import { buildEnumsOptions, Button, Choice, FieldAccessor, Icon, ObjectAccessor, Page, Password, TextField } from '@/components'; import { Passport } from '@/pages/admins/edit'; interface Props { @@ -42,12 +41,11 @@ export default function (props: Props): JSX.Element { export function Login(props: Props): JSX.Element { const ctx = useApp(); - const opt = useOptions(); - const nav = useNavigate(); ctx.api.cache('/passports'); const [passports, setPassports] = createSignal>([]); + const passport = FieldAccessor('passport', 'password'); onMount(async () => { const r = await ctx.api.get>('/passports'); @@ -58,39 +56,30 @@ export function Login(props: Props): JSX.Element { setPassports(r.body!.map((v)=>[v.id,v.desc])); }); - const account = new ObjectAccessor({ type: 'passwords', username: '', password: '' }); - - return