diff --git a/devcloud/etc/application.toml b/devcloud/etc/application.toml new file mode 100644 index 0000000..dfc8587 --- /dev/null +++ b/devcloud/etc/application.toml @@ -0,0 +1,20 @@ +[app] + name = "devcloud" + description = "app desc" + address = "localhost" + encrypt_key = "defualt app encrypt key" + +[datasource] + provider = "mysql" + host = "127.0.0.1" + port = 3306 + database = "devcloud_go18" + username = "root" + password = "123456" + auto_migrate = true + debug = true + +[http] + host = "127.0.0.1" + port = 8080 + path_prefix = "api" \ No newline at end of file diff --git a/devcloud/mcenter/apps/token/README.md b/devcloud/mcenter/apps/token/README.md index b885cc7..2f9dbcf 100644 --- a/devcloud/mcenter/apps/token/README.md +++ b/devcloud/mcenter/apps/token/README.md @@ -4,7 +4,7 @@ + 撤销访问令牌: 令牌失效了 Logout + 校验访问令牌:检查令牌的合法性, 是不是伪造的 -## 详情设计的时候 +## 详情设计 字段(业务需求) @@ -18,3 +18,18 @@ 问题: 无刷新功能, 令牌到期了,自动退出了, 过期时间设置长一点, 长时间不过期 又有安全问题 1. 业务功能: 令牌的刷新, 令牌过期了过后,允许用户进行刷新(需要使用刷新Token来刷新, 刷新Token也是需要有过期时间, 这个时间决定回话长度),有了刷新token用户不会出现 使用中被中断的情况, 并且长时间未使用,系统也户自动退出(刷新Token过期) + +## 转化为接口定义 + +```go +type Service interface { + // 颁发访问令牌: Login + IssueToken(context.Context, *IssueTokenRequest) (*Token, error) + // 撤销访问令牌: 令牌失效了 Logout + RevolkToken(context.Context, *RevolkTokenRequest) (*Token, error) + + // 校验访问令牌:检查令牌的合法性, 是不是伪造的 + ValiateToken(context.Context, *ValiateTokenRequest) (*Token, error) +} +``` + diff --git a/devcloud/mcenter/apps/token/docs/refresh.drawio b/devcloud/mcenter/apps/token/docs/refresh.drawio index e69de29..a78764e 100644 --- a/devcloud/mcenter/apps/token/docs/refresh.drawio +++ b/devcloud/mcenter/apps/token/docs/refresh.drawio @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/devcloud/mcenter/apps/token/impl/impl.go b/devcloud/mcenter/apps/token/impl/impl.go new file mode 100644 index 0000000..4f9d22e --- /dev/null +++ b/devcloud/mcenter/apps/token/impl/impl.go @@ -0,0 +1 @@ +package impl diff --git a/devcloud/mcenter/apps/token/impl/token.go b/devcloud/mcenter/apps/token/impl/token.go new file mode 100644 index 0000000..4f9d22e --- /dev/null +++ b/devcloud/mcenter/apps/token/impl/token.go @@ -0,0 +1 @@ +package impl diff --git a/devcloud/mcenter/apps/token/interfaceg.go b/devcloud/mcenter/apps/token/interfaceg.go index aedc9dd..8485347 100644 --- a/devcloud/mcenter/apps/token/interfaceg.go +++ b/devcloud/mcenter/apps/token/interfaceg.go @@ -12,7 +12,7 @@ type Service interface { RevolkToken(context.Context, *RevolkTokenRequest) (*Token, error) // 校验访问令牌:检查令牌的合法性, 是不是伪造的 - ValidateToken(context.Context, *ValidateTokenRequest) (*Token, error) + ValiateToken(context.Context, *ValiateTokenRequest) (*Token, error) } // 用户会给我们 用户的身份凭证,用于换取Token @@ -63,8 +63,25 @@ func (p IssueParameter) SetAccessToken(v string) { p["access_token"] = v } -type RevolkTokenRequest struct { +func NewRevolkTokenRequest(at, rk string) *RevolkTokenRequest { + return &RevolkTokenRequest{ + AccessToken: at, + RefreshToken: rk, + } } -type ValidateTokenRequest struct { +// 万一的Token泄露, 不知道refresh_token,也没法推出 +type RevolkTokenRequest struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func NewValiateTokenRequest(accessToken string) *ValiateTokenRequest { + return &ValiateTokenRequest{ + AccessToken: accessToken, + } +} + +type ValiateTokenRequest struct { + AccessToken string `json:"access_token"` } diff --git a/devcloud/mcenter/apps/token/model.go b/devcloud/mcenter/apps/token/model.go index 28c454e..eb3a805 100644 --- a/devcloud/mcenter/apps/token/model.go +++ b/devcloud/mcenter/apps/token/model.go @@ -1,6 +1,12 @@ package token -import "time" +import ( + "fmt" + "time" + + "github.com/infraboard/mcube/v2/exception" + "github.com/infraboard/mcube/v2/tools/pretty" +) // 需要存储到数据库里面的对象(表) @@ -41,6 +47,97 @@ type Token struct { Extras map[string]string `json:"extras" gorm:"column:extras;serializer:json;type:json" description:"其他扩展信息"` } +func (t *Token) TableName() string { + return "tokens" +} + +// 判断访问令牌是否过期,没设置代表用不过期 +func (t *Token) IsAccessTokenExpired() error { + if t.AccessTokenExpiredAt != nil { + // now expiredTime + expiredSeconds := time.Since(*t.AccessTokenExpiredAt).Seconds() + if expiredSeconds > 0 { + return exception.NewAccessTokenExpired("access token %s 过期了 %f秒", + t.AccessToken, expiredSeconds) + } + } + + return nil +} + +// 判断刷新Token是否过期 +func (t *Token) IsRreshTokenExpired() error { + if t.RefreshTokenExpiredAt != nil { + expiredSeconds := time.Since(*t.RefreshTokenExpiredAt).Seconds() + if expiredSeconds > 0 { + return exception.NewRefreshTokenExpired("refresh token %s 过期了 %f秒", + t.RefreshToken, expiredSeconds) + } + } + + return nil +} + +// 刷新Token的过期时间 是一个系统配置, 刷新token的过期时间 > 访问token的时间 +// 给一些默认设置: 刷新token的过期时间 = 访问token的时间 * 4 +func (t *Token) SetExpiredAtByDuration(duration time.Duration, refreshMulti uint) { + t.SetAccessTokenExpiredAt(time.Now().Add(duration)) + t.SetRefreshTokenExpiredAt(time.Now().Add(duration * time.Duration(refreshMulti))) +} + +func (t *Token) SetAccessTokenExpiredAt(v time.Time) { + t.AccessTokenExpiredAt = &v +} + +func (t *Token) SetRefreshAt(v time.Time) { + t.RefreshAt = &v +} + +func (t *Token) AccessTokenExpiredTTL() int { + if t.AccessTokenExpiredAt != nil { + return int(t.AccessTokenExpiredAt.Sub(t.IssueAt).Seconds()) + } + return 0 +} + +func (t *Token) SetRefreshTokenExpiredAt(v time.Time) { + t.RefreshTokenExpiredAt = &v +} + +func (t *Token) String() string { + return pretty.ToJSON(t) +} + +func (t *Token) SetIssuer(issuer string) *Token { + t.Issuer = issuer + return t +} + +func (t *Token) SetSource(source SOURCE) *Token { + t.Source = source + return t +} + +func (t *Token) UserIdString() string { + return fmt.Sprintf("%d", t.UserId) +} + +func (t *Token) CheckRefreshToken(refreshToken string) error { + if t.RefreshToken != refreshToken { + return exception.NewPermissionDeny("refresh token not conrect") + } + return nil +} + +func (t *Token) Lock(l LOCK_TYPE, reason string) { + if t.Status == nil { + t.Status = NewStatus() + } + t.Status.LockType = l + t.Status.LockReason = reason + t.Status.SetLockAt(time.Now()) +} + func NewStatus() *Status { return &Status{} } diff --git a/devcloud/mcenter/apps/user/README.md b/devcloud/mcenter/apps/user/README.md index abce439..7813db2 100644 --- a/devcloud/mcenter/apps/user/README.md +++ b/devcloud/mcenter/apps/user/README.md @@ -1,2 +1,46 @@ # 用户管理 ++ 创建用户 ++ 删除用户 ++ 更新用户 ++ 用户列表 ++ 用户详情 ++ 重置密码 + +## 详情设计 + +```go +// 定义User包的能力 就是接口定义 +// 站在使用放的角度来定义的 userSvc.Create(ctx, req), userSvc.DeleteUser(id) +// 接口定义好了,不要试图 随意修改接口, 要保证接口的兼容性 +type Service interface { + // 创建用户 + CreateUser(context.Context, *CreateUserRequest) (*User, error) + // 删除用户 + DeleteUser(context.Context, *DeleteUserRequest) (*User, error) + // 查询用户详情 + DescribeUser(context.Context, *DescribeUserRequest) (*User, error) + // 查询用户列表 + QueryUser(context.Context, *QueryUserRequest) (*types.Set[*User], error) +} +``` + +### 业务功能 + +加解迷的方式有分类: +1. Hash (消息摘要): 单向Hash, 可以通过原文 获取摘要信息,但是无法通过摘要信息推断原文, 只要摘要信息相同,原文就相同: md5, sha*, bcrypt ... +2. 对称加解密: 加密和解密的秘密(key) 用于数据的加解密文 +3. 非对称加解密: 加密(公钥)和解密(私用)不是用的同一个秘密, 用于密码的加解密 + +1. 用户密码怎么存储的问题, 存储用户密码的hash,避免直接存储用户密码。 11111 -> abcd, 可能导致 用户的秘密在其他平台泄露 知道了这个影视关系abcd --> 1111, +能不能有什么办法解决这个问题, 加盐: 相同密码 --> 每次hash会产生不同的结果 + +![alt text](image.png) + + +password --> hash +password + 随机字符串(salt) --> (salt)hash它也是随机 + +输入: password + 随机字符串(salt: 原来hash中的sal部分) == hash它也是随机(数据库) + + diff --git a/devcloud/mcenter/apps/user/enum.go b/devcloud/mcenter/apps/user/enum.go new file mode 100644 index 0000000..caffafd --- /dev/null +++ b/devcloud/mcenter/apps/user/enum.go @@ -0,0 +1,48 @@ +package user + +type PROVIDER int32 + +const ( + // 本地数据库 + PROVIDER_LOCAL PROVIDER = 0 + // 来源LDAP + PROVIDER_LDAP PROVIDER = 1 + // 来源飞书 + PROVIDER_FEISHU PROVIDER = 2 + // 来源钉钉 + PROVIDER_DINGDING PROVIDER = 3 + // 来源企业微信 + PROVIDER_WECHAT_WORK PROVIDER = 4 +) + +type CEATE_TYPE int + +const ( + // 系统初始化 + CREATE_TYPE_INIT = iota + // 管理员创建 + CREATE_TYPE_ADMIN + // 用户自己注册 + CREATE_TYPE_REGISTRY +) + +type TYPE int32 + +const ( + TYPE_SUB TYPE = 0 +) + +type SEX int + +const ( + SEX_UNKNOWN = iota + SEX_MALE + SEX_FEMALE +) + +type DESCRIBE_BY int + +const ( + DESCRIBE_BY_ID DESCRIBE_BY = iota + DESCRIBE_BY_USERNAME +) diff --git a/devcloud/mcenter/apps/user/image.png b/devcloud/mcenter/apps/user/image.png new file mode 100644 index 0000000..c88d149 Binary files /dev/null and b/devcloud/mcenter/apps/user/image.png differ diff --git a/devcloud/mcenter/apps/user/impl/impl.go b/devcloud/mcenter/apps/user/impl/impl.go new file mode 100644 index 0000000..bfc784e --- /dev/null +++ b/devcloud/mcenter/apps/user/impl/impl.go @@ -0,0 +1,34 @@ +package impl + +import ( + "122.51.31.227/go-course/go18/devcloud/mcenter/apps/user" + "github.com/infraboard/mcube/v2/ioc" + "github.com/infraboard/mcube/v2/ioc/config/datasource" +) + +func init() { + ioc.Controller().Registry(&UserServiceImpl{}) +} + +var _ user.Service = (*UserServiceImpl)(nil) + +// 他是user service 服务的控制器 +type UserServiceImpl struct { + ioc.ObjectImpl +} + +func (i *UserServiceImpl) Init() error { + // 自动创建表 + if datasource.Get().AutoMigrate { + err := datasource.DB().AutoMigrate(&user.User{}) + if err != nil { + return err + } + } + return nil +} + +// 定义托管到Ioc里面的名称 +func (i *UserServiceImpl) Name() string { + return user.APP_NAME +} diff --git a/devcloud/mcenter/apps/user/impl/impl_test.go b/devcloud/mcenter/apps/user/impl/impl_test.go new file mode 100644 index 0000000..ed680d2 --- /dev/null +++ b/devcloud/mcenter/apps/user/impl/impl_test.go @@ -0,0 +1 @@ +package impl_test diff --git a/devcloud/mcenter/apps/user/impl/user.go b/devcloud/mcenter/apps/user/impl/user.go new file mode 100644 index 0000000..74257c4 --- /dev/null +++ b/devcloud/mcenter/apps/user/impl/user.go @@ -0,0 +1,109 @@ +package impl + +import ( + "context" + + "122.51.31.227/go-course/go18/devcloud/mcenter/apps/user" + "github.com/infraboard/mcube/v2/exception" + "github.com/infraboard/mcube/v2/ioc/config/datasource" + "github.com/infraboard/mcube/v2/types" + "gorm.io/gorm" +) + +// 创建用户 +func (i *UserServiceImpl) CreateUser( + ctx context.Context, + req *user.CreateUserRequest) ( + *user.User, error) { + // 1. 校验用户参数 + if err := req.Validate(); err != nil { + return nil, err + } + + // 2. 生成一个User对象(ORM对象) + ins := user.NewUser(req) + + if err := datasource.DBFromCtx(ctx). + Create(ins). + Error; err != nil { + return nil, err + } + + // 4. 返回结果 + return ins, nil +} + +// 删除用户 +func (i *UserServiceImpl) DeleteUser( + ctx context.Context, + req *user.DeleteUserRequest, +) (*user.User, error) { + u, err := i.DescribeUser(ctx, + user.NewDescribeUserRequestById(req.Id)) + if err != nil { + return nil, err + } + + return u, datasource.DBFromCtx(ctx). + Where("id = ?", req.Id). + Delete(&user.User{}). + Error +} + +// 查询用户列表 +func (i *UserServiceImpl) QueryUser( + ctx context.Context, + req *user.QueryUserRequest) ( + *types.Set[*user.User], error) { + set := types.New[*user.User]() + + query := datasource.DBFromCtx(ctx).Model(&user.User{}) + + // 查询总量 + err := query.Count(&set.Total).Error + if err != nil { + return nil, err + } + + err = query. + Order("created_at desc"). + Offset(int(req.ComputeOffset())). + Limit(int(req.PageSize)). + Find(&set.Items). + Error + if err != nil { + return nil, err + } + + return set, nil +} + +// 查询用户详情 +func (i *UserServiceImpl) DescribeUser( + ctx context.Context, + req *user.DescribeUserRequest) ( + *user.User, error) { + + query := datasource.DBFromCtx(ctx) + + // 1. 构造我们的查询条件 + switch req.DescribeBy { + case user.DESCRIBE_BY_ID: + query = query.Where("id = ?", req.DescribeValue) + case user.DESCRIBE_BY_USERNAME: + query = query.Where("user_name = ?", req.DescribeValue) + } + + // SELECT * FROM `users` WHERE username = 'admin' ORDER BY `users`.`id` LIMIT 1 + ins := &user.User{} + if err := query.First(ins).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, exception.NewNotFound("user %s not found", req.DescribeValue) + } + return nil, err + } + + // 数据库里面存储的就是Hash + ins.SetIsHashed() + return ins, nil +} diff --git a/devcloud/mcenter/apps/user/impl/user_test.go b/devcloud/mcenter/apps/user/impl/user_test.go new file mode 100644 index 0000000..ed680d2 --- /dev/null +++ b/devcloud/mcenter/apps/user/impl/user_test.go @@ -0,0 +1 @@ +package impl_test diff --git a/devcloud/mcenter/apps/user/interface.go b/devcloud/mcenter/apps/user/interface.go new file mode 100644 index 0000000..4159aa4 --- /dev/null +++ b/devcloud/mcenter/apps/user/interface.go @@ -0,0 +1,83 @@ +package user + +import ( + "context" + "slices" + + "github.com/infraboard/mcube/v2/http/request" + "github.com/infraboard/mcube/v2/ioc" + "github.com/infraboard/mcube/v2/types" +) + +const ( + APP_NAME = "user" +) + +func GetService() Service { + return ioc.Controller().Get(APP_NAME).(Service) +} + +// 定义User包的能力 就是接口定义 +// 站在使用放的角度来定义的 userSvc.Create(ctx, req), userSvc.DeleteUser(id) +// 接口定义好了,不要试图 随意修改接口, 要保证接口的兼容性 +type Service interface { + // 创建用户 + CreateUser(context.Context, *CreateUserRequest) (*User, error) + // 删除用户 + DeleteUser(context.Context, *DeleteUserRequest) (*User, error) + // 查询用户详情 + DescribeUser(context.Context, *DescribeUserRequest) (*User, error) + // 查询用户列表 + QueryUser(context.Context, *QueryUserRequest) (*types.Set[*User], error) +} + +func NewQueryUserRequest() *QueryUserRequest { + return &QueryUserRequest{ + PageRequest: request.NewDefaultPageRequest(), + UserIds: []uint64{}, + } +} + +type QueryUserRequest struct { + *request.PageRequest + UserIds []uint64 `form:"user" json:"user"` +} + +func (r *QueryUserRequest) AddUser(userIds ...uint64) *QueryUserRequest { + for _, uid := range userIds { + if !slices.Contains(r.UserIds, uid) { + r.UserIds = append(r.UserIds, uid) + } + } + return r +} + +func NewDescribeUserRequestById(id string) *DescribeUserRequest { + return &DescribeUserRequest{ + DescribeValue: id, + } +} + +func NewDescribeUserRequestByUserName(username string) *DescribeUserRequest { + return &DescribeUserRequest{ + DescribeBy: DESCRIBE_BY_USERNAME, + DescribeValue: username, + } +} + +// 同时支持通过Id来查询,也要支持通过username来查询 +type DescribeUserRequest struct { + DescribeBy DESCRIBE_BY `json:"describe_by"` + DescribeValue string `json:"describe_value"` +} + +func NewDeleteUserRequest(id string) *DeleteUserRequest { + return &DeleteUserRequest{ + Id: id, + } +} + +// 删除用户的请求 +type DeleteUserRequest struct { + Id string `json:"id"` +} diff --git a/devcloud/mcenter/apps/user/model.go b/devcloud/mcenter/apps/user/model.go new file mode 100644 index 0000000..03448de --- /dev/null +++ b/devcloud/mcenter/apps/user/model.go @@ -0,0 +1,124 @@ +package user + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/infraboard/modules/iam/apps" + "golang.org/x/crypto/bcrypt" +) + +func NewUser(req *CreateUserRequest) *User { + req.PasswordHash() + + return &User{ + ResourceMeta: *apps.NewResourceMeta(), + CreateUserRequest: *req, + } +} + +// 用于存放 存入数据库的对象(PO) +type User struct { + // 基础数据 + apps.ResourceMeta + // 用户传递过来的请求 + CreateUserRequest + + // 密码强度 + PwdIntensity int8 `json:"pwd_intensity" gorm:"column:pwd_intensity;type:tinyint(1);not null" optional:"true"` +} + +func (u *User) String() string { + dj, _ := json.Marshal(u) + return string(dj) +} + +// 判断该用户的密码是否正确 +func (u *User) CheckPassword(password string) error { + return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) +} + +// 声明你这个对象存储在users表里面 +// orm 负责调用TableName() 来动态获取你这个对象要存储的表的名称 +func (u *User) TableName() string { + return "users" +} + +func NewCreateUserRequest() *CreateUserRequest { + return &CreateUserRequest{ + Extras: map[string]string{}, + } +} + +type CreateUserRequest struct { + // 账号提供方 + Provider PROVIDER `json:"provider" gorm:"column:provider;type:tinyint(1);not null;index" description:"账号提供方"` + // 创建方式 + CreateType CEATE_TYPE `json:"create_type" gorm:"column:create_type;type:tinyint(1);not null;index" optional:"true"` + // 用户名 + UserName string `json:"user_name" gorm:"column:user_name;type:varchar(100);not null;uniqueIndex" description:"用户名"` + // 密码(Hash过后的) + Password string `json:"password" gorm:"column:password;type:varchar(200);not null" description:"用户密码"` + // 用户描述 + Description string `json:"description" gorm:"column:description;type:varchar(200);not null" description:"用户描述"` + // 用户类型 + Type TYPE `json:"type" gorm:"column:type;type:varchar(200);not null" description:"用户类型"` + // 用户描述 + Domain string `json:"domain" gorm:"column:domain;type:varchar(200);" description:"用户所属域"` + + // 支持接口调用 + EnabledApi bool `json:"enabled_api" gorm:"column:enabled_api;type:tinyint(1)" optional:"true" description:"支持接口调用"` + // 是不是管理员 + IsAdmin bool `json:"is_admin" gorm:"column:is_admin;type:tinyint(1)" optional:"true" description:"是不是管理员"` + // 用户状态,01:正常,02:冻结 + Locked bool `json:"stat" gorm:"column:stat;type:tinyint(1)" optional:"true" description:"用户状态, 01:正常, 02:冻结"` + // 激活,1:激活,0:未激活 + Activate bool `json:"activate" gorm:"column:activate;type:tinyint(1)" optional:"true" description:"激活, 1: 激活, 0: 未激活"` + // 生日 + Birthday *time.Time `json:"birthday" gorm:"column:birthday;type:varchar(200)" optional:"true" description:"生日"` + // 昵称 + NickName string `json:"nick_name" gorm:"column:nick_name;type:varchar(200)" optional:"true" description:"昵称"` + // 头像图片 + UserIcon string `json:"user_icon" gorm:"column:user_icon;type:varchar(500)" optional:"true" description:"头像图片"` + // 性别, 1:男,2:女,0:保密 + Sex SEX `json:"sex" gorm:"column:sex;type:tinyint(1)" optional:"true" description:"性别, 1:男, 2:女, 0: 保密"` + + // 邮箱 + Email string `json:"email" gorm:"column:email;type:varchar(200);index" description:"邮箱" unique:"true"` + // 邮箱是否验证ok + IsEmailConfirmed bool `json:"is_email_confirmed" gorm:"column:is_email_confirmed;type:tinyint(1)" optional:"true" description:"邮箱是否验证ok"` + // 手机 + Mobile string `json:"mobile" gorm:"column:mobile;type:varchar(200);index" optional:"true" description:"手机" unique:"true"` + // 手机释放验证ok + IsMobileConfirmed bool `json:"is_mobile_confirmed" gorm:"column:is_mobile_confirmed;type:tinyint(1)" optional:"true" description:"手机释放验证ok"` + // 手机登录标识 + MobileTGC string `json:"mobile_tgc" gorm:"column:mobile_tgc;type:char(64)" optional:"true" description:"手机登录标识"` + // 标签 + Label string `json:"label" gorm:"column:label;type:varchar(200);index" optional:"true" description:"标签"` + // 其他扩展信息 + Extras map[string]string `json:"extras" gorm:"column:extras;serializer:json;type:json" optional:"true" description:"其他扩展信息"` + + isHashed bool `json:"-"` +} + +func (req *CreateUserRequest) SetIsHashed() { + req.isHashed = true +} + +func (req *CreateUserRequest) Validate() error { + if req.UserName == "" || req.Password == "" { + return fmt.Errorf("用户名或者密码需要填写") + } + return nil +} + +func (req *CreateUserRequest) PasswordHash() { + if req.isHashed { + return + } + + b, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + req.Password = string(b) + req.isHashed = true +}