用户服务
(只是进行一个学习笔记的记录)
身份管理
- 身份认证:身份证,邮箱,用户名,密码,手机号
- 授权:服务端确认了用户的身份,但是用户无法直接与服务端进行交互,实际情况会委派给客户端进行一定的数据资源操作。授权的结果一般会是 cookie ,session,token .
- 鉴权:对上述确认过的信息和凭证进行解析和确认
- 权限的控制:对可执行的操作进行组合配置,成为一个权限的列表,根据执行者的权限,允许或者禁止。
Hertz Middleware
用户登录,注册
frontend 前端文件夹中进行 登录路由 的编写,在 main.go 文件夹中,添加以下内容加载前端,以及登录逻辑。
main.go:
func main() {
_ = godotenv.Load()
// init dal
// dal.Init()
rpc.InitClient()
address := conf.GetConf().Hertz.Address
h := server.New(server.WithHostPorts(address))
registerMiddleware(h)
// add a ping route to test
h.GET("/ping",func(c context.Context, ctx *app.RequestContext) {
ctx.JSON(consts.StatusOK, utils.H{"ping": "pong"})
})
router.GeneratedRegister(h)
h.LoadHTMLGlob("template/*")
h.Static("/static", "./")
h.GET("/about",func(c context.Context, ctx *app.RequestContext) {
ctx.HTML(consts.StatusOK, "about", utils.H{"title": "About"})
})
h.GET("/sign-in", func(c context.Context, ctx *app.RequestContext) {
data := utils.H{
"Title": "Sign In",
"Next": ctx.Query("next"),
}
ctx.HTML(consts.StatusOK, "sign-in", data)
})
h.GET("/sign-up", func(c context.Context, ctx *app.RequestContext) {
ctx.HTML(consts.StatusOK, "sign-up", utils.H{"title": "Sign Up"})
})
h.Spin()
}
其他的可以先不用管,就看/sign-in 部分:
//注册 sign-in http路由, 执行函数是后面的
//定义了一个 回调函数 作为处理逻辑
//c context.Context用于控制超时、取消等上下文管理。
h.GET("/sign-in", func(c context.Context, ctx *app.RequestContext) {
data := utils.H{
"Title": "Sign In",
"Next": ctx.Query("next"),
}
ctx.HTML(consts.StatusOK, "sign-in", data)
})
- ctx *app.RequestContext:Kitex 的 RequestContext,用于访问请求相关信息(如查询参数、请求体)和返回响应。
- “Title”: “Sign In”:设置模板变量 Title 为 “Sign In”,用于页面显示
- ctx.Query(“next”) 用于获取 URL 查询参数 next,即用户访问 /sign-in?next=/dashboard 时,Next 变量会被赋值为 “/dashboard”
- Next 可能用于登录成功后的跳转,即登录完成后,重定向到 next 指定的页面
ctx.HTML(consts.StatusOK, "sign-in", data)
- ctx.HTML() 方法用于渲染 HTML 页面
- “sign-in”:模板文件名,通常指 sign-in.html(这里省掉了,是因为在 html 文件中进行了编辑,改动)
- data:传递给模板的渲染数据,在 sign-in.html 里可以使用 Title 和 Next。
前端登录html
这里使用 define 定义即可 在 渲染的时候,去掉 tmpl 后缀
{{define "sign-in"}} <!-- 定义一个名为 "sign-in" 的模板 -->
{{ template "header" . }} <!-- 引用并渲染名为 "header" 的模板,通常包含 HTML 头部信息 -->
<div class="row justify-content-center"> <!-- Bootstrap 的行布局,使内容居中 -->
<div class="col-4"> <!-- 设定列的宽度为 4/12,使其在页面中适当居中 -->
<!-- 登录表单,使用 POST 方法提交 -->
<form method="post" action="/auth/login?next={{ .Next }}">
<!-- action="/auth/login?next={{ .Next }}"
说明:
- 当表单提交时,数据将被发送到 `/auth/login` 端点。
- `next={{ .Next }}` 可能用于重定向用户到原先想访问的页面(如未登录前访问的页面)。
-->
<!-- 邮箱输入框 -->
<div class="mb-3">
<label for="email" class="form-label">
Email {{template "required"}}
<!-- 显示“Email”标签,并插入 "required" 模板(通常用于显示必填字段标记 *) -->
</label>
<input type="email" class="form-control" id="email" name="email">
<!-- type="email" 确保输入值符合 Email 格式 -->
</div>
<!-- 密码输入框 -->
<div class="mb-3">
<label for="password" class="form-label">
Password {{template "required"}}
<!-- 显示“Password”标签,并插入 "required" 模板(通常用于显示必填字段标记 *) -->
</label>
<input type="password" class="form-control" id="password" name="password">
<!-- type="password" 确保输入值不会明文显示 -->
</div>
<!-- 注册提示 -->
<div class="mb-3">
Don't have an account? Click here to <a href="/sign-up">Sign Up</a>.
<!-- 如果用户没有账号,提供跳转到注册页面的链接 -->
</div>
<!-- 提交按钮 -->
<button type="submit" class="btn btn-primary">Sign In</button>
</form> <!-- 表单结束 -->
</div>
</div>
{{ template "footer" .}} <!-- 引用并渲染名为 "footer" 的模板,通常包含 HTML 页脚信息 -->
{{end}} <!-- 结束 "sign-in" 模板定义 -->
- {{ template “header” . }} 引入 header 头文件, footer 尾文件
这个时候就有一个问题了,就是点击 sign-in 怎么去 进入这个 sign-in 文件渲染的 html 页面呢,那我们来 进行改动吧。
加入href=“/sign-in”,即可完成跳转。
<div class="ms-3">
<a type="button" class="btn btn-primary" href="/sign-in">Sign In</a>
</div>
- class=“ms-3” ,ms-3 是 Bootstrap 的 margin-start 类(即左外边距)
- < a > :超链接元素,用户点击后跳转到 /sign-in 登录页面
- class=“btn btn-primary”: btn:Bootstrap 按钮基础样式,btn-primary:使用 Bootstrap 的蓝色主按钮样式。
提交数据
这个前端输入的数据怎么传给后端呢?
在 sign-in 模板中,表单 () 使用了 POST 方法提交数据到 /auth/login 端点
<form method="post" action="/auth/login?next={{ .Next }}">
<input type="email" class="form-control" id="email" name="email">
<input type="password" class="form-control" id="password" name="password">
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
- method=“post”:数据不会通过 URL 传递,而是通过 HTTP 请求体 传输,更加安全
- name=“email” 和 name=“password”:输入的数据会以 键值对 的形式提交到服务器,键名对应 name 属性
数据提交方式:
当用户点击 Sign In 按钮时,浏览器会向后端发送 HTTP POST 请求,其请求体(body)大概是:
email=user@example.com&password=123456
处理请求:
示例:
func main() {
r := gin.Default()
// 处理登录请求
r.POST("/auth/login", func(c *gin.Context) {
// 从表单解析 email 和 password
email := c.PostForm("email") // 取出用户输入的 email
password := c.PostForm("password") // 取出用户输入的 password
// 打印日志(生产环境不要打印密码)
println("Email:", email, "Password:", password)
// 假设这里是数据库验证逻辑
if email == "user@example.com" && password == "123456" {
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Invalid email or password"})
}
})
r.Run(":3000") // 监听 3000 端口
}
但是大多数都是使用框架进行开发的,只需要编写登录逻辑即可:
func (s *LoginService) Run(req *user.LoginReq) (resp *user.LoginResp, err error) {
// Finish your business logic.
if req.Email == "" || req.Password == "" {
return nil, errors.New("email or password is empty")
}
row, err := model.GetByEmail(s.ctx, mysql.DB, req.Email)
if err != nil {
return nil, err
}
err = bcrypt.CompareHashAndPassword([]byte(row.PasswordHashed), []byte(req.Password))
if err != nil {
return nil, err
}
resp = &user.LoginResp{
UserId: int32(row.ID),
}
return resp, nil
}
重定向到主页:
func Login(ctx context.Context, c *app.RequestContext) {
var err error
var req auth.LoginReq
// 解析前端传来的 JSON 或表单数据,填充到req 中,并进行格式校验
err = c.BindAndValidate(&req)
if err != nil {
// 如果解析失败,返回错误信息
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
// 调用 LoginService 处理登录逻辑(检查用户是否存在、密码是否正确等)
redirect, err := service.NewLoginService(ctx, c).Run(&req)
if err != nil {
// 如果登录失败,返回错误信息
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
// 登录成功后,进行页面重定向
// ⚠️ 注意:通常重定向应该使用 302 (consts.StatusFound) 而不是 200 (StatusOK)
c.Redirect(consts.StatusOK, []byte(redirect))
// 登录成功后的 JSON 响应(已注释掉,因为使用了 Redirect)
// utils.SendSuccessResponse(ctx, c, consts.StatusOK, "done")
}
然后这里的 redirect 逻辑:
前端处理逻辑
func (h *LoginService) Run(req *auth.LoginReq) (redirect string, err error) {
// defer func() {
// hlog.CtxInfof(h.Context, "req = %+v", req) // 记录请求信息
// hlog.CtxInfof(h.Context, "resp = %+v", resp) // 记录响应信息
// }()
// 调用远程 RPC 服务,验证用户的邮箱和密码是否正确
resp, err := rpc.UserClient.Login(h.Context, &user.LoginReq{
Email: req.Email,
Password: req.Password,
})
if err != nil {
// 如果 RPC 调用失败,返回错误
return "", err
}
// 获取当前会话(Session)
session := sessions.Default(h.RequestContext)
// 在 Session 中存储用户 ID,表示用户已登录
session.Set("user_id", resp.UserId)
// 保存 Session 变更,如果失败则返回错误
err = session.Save()
if err != nil {
return "", err
}
// 默认重定向到主页 "/"
redirect = "/"
// 如果 `req.Next` 不为空,则重定向到用户指定的页面
if req.Next != "" {
redirect = req.Next
}
return
}
这里的 session 是基于 redis 存储的一个会话。
登录登出的一个界面转变设计,
判断是否有 use.id 如果有,就显示下拉菜单,没有就进行显示 Sign-in
{{ if .user_id }}
<div class="dropdown">
<div class="ms-3 dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-user fa-lg"></i>
<span>Yehan</span>
</div>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Order Center</a></li>
<li>
<form action="/auth/logout" method="post">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
{{ else }}
<div class="ms-3">
<a type="button" class="btn btn-primary" href="/sign-in">Sign In</a>
</div>
{{end}}
</div>
由于 session的设计是后面每个都要进行去判断,就可以抽出来,设计一个函数进行调用:
func WarpResponse(ctx context.Context, c *app.RequestContext, content map[string]any) map[string]any {
userId := frontendUtils.GetUserIdFromCtx(ctx)
content["user_id"] = userId
if userId > 0 {
cartResp, err := rpc.CartClient.GetCart(ctx, &cart.GetCartReq{
UserId: uint32(userId),
})
if err == nil && cartResp != nil {
content["cart_num"] = len(cartResp.Items)
}
}
return content
}
- 用户注册
同样改一下代码,biz/handler/auth/auth_service.go, biz/service/register.go
func Register(ctx context.Context, c *app.RequestContext) {
var err error
var req auth.RegisterReq
err = c.BindAndValidate(&req)
if err != nil {
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
_, err = service.NewRegisterService(ctx, c).Run(&req)
if err != nil {
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
//utils.SendSuccessResponse(ctx, c, consts.StatusOK, resp)
c.Redirect(consts.StatusOK, []byte("/"))
}
func (h *RegisterService) Run(req *auth.RegisterReq) (resp *common.Empty, err error) {
//defer func() {
// hlog.CtxInfof(h.Context, "req = %+v", req)
// hlog.CtxInfof(h.Context, "resp = %+v", resp)
//}()
// todo edit your code
userResp, err := rpc.UserClient.Register(h.Context, &user.RegisterReq{
Email: req.Email,
Password: req.Password,
PasswordConfirm: req.PasswordConfirm,
})
session := sessions.Default(h.RequestContext)
session.Set("user_id", userResp.UserId)
err = session.Save()
if err != nil {
return nil, err
}
return
}
以及注册的html页面:
{{define "sign-up"}}
{{ template "header" . }}
<div class="row justify-content-center">
<div class="col-4">
<form method="post" action="/auth/register">
<div class="mb-3">
<label for="email" class="form-label">Email {{template "required"}}</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password {{template "required"}}</label>
<input type="password" class="form-control" id="password" name="password">
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">Password Confirm {{template "required"}}</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm">
</div>
<div class="mb-3">
Already have a account, click here to <a href="/sign-in">Sign In</a>.
</div>
<button type="submit" class="btn btn-primary">Sign Up</button>
</form>
</div>
</div>
{{ template "footer" .}}
{{end}}
在用户服务中进行改动添加注册逻辑;
func (s *RegisterService) Run(req *user.RegisterReq) (resp *user.RegisterResp, err error) {
// Finish your business logic.
if req.Email == "" || req.Password == "" || req.PasswordConfirm == "" {
return nil, errors.New("email or password is empty")
}
if req.Password != req.PasswordConfirm {
return nil, errors.New("password not match")
}
passwordHashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
newUser := &model.User{
Email: req.Email,
PasswordHashed: string(passwordHashed),
}
err = model.Create(s.ctx, mysql.DB, newUser)
if err != nil {
return nil, err
}
return &user.RegisterResp{UserId: int32(newUser.ID)}, nil
}
用户登出
biz/handler/auth/auth_service.go, biz/service/logout.go
<form action="/auth/logout" method="post">
<button class="dropdown-item" type="submit">Logout</button>
</form>
在上面idl 的生成代码,用户登出的基本代码也已经生成。
改一下代码,
func Logout(ctx context.Context, c *app.RequestContext) {
var err error
var req common.Empty
err = c.BindAndValidate(&req)
if err != nil {
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
_, err = service.NewLogoutService(ctx, c).Run(&req)
if err != nil {
utils.SendErrResponse(ctx, c, consts.StatusOK, err)
return
}
//utils.SendSuccessResponse(ctx, c, consts.StatusOK, resp)
c.Redirect(consts.StatusOK, []byte("/"))
}
func (h *LogoutService) Run(req *common.Empty) (resp *common.Empty, err error) {
//defer func() {
// hlog.CtxInfof(h.Context, "req = %+v", req)
// hlog.CtxInfof(h.Context, "resp = %+v", resp)
//}()
// todo edit your code
session := sessions.Default(h.RequestContext)
session.Clear()
err = session.Save()
if err != nil {
return nil, err
}
return
}
优化
- 点击home 可以直接回到主界面:
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
- about 界面的设计:
<li class="nav-item">
<a class="nav-link" href="/about">About</a>
</li>
关于吉祥物的介绍:
{{define "about"}}
{{template "header" .}}
<div>
<img src="/static/image/logo.jpg" alt="CloudWeGo" class="col-lg-2 col-md-3 col-sm-4"/>
<p>This is community a driven project.</p>
</div>
{{template "footer"}}
{{end}}
- 登录完成后可以自动返回上一个页面:
使用 next 记录上一个页面的信息,
h.GET("/sign-in", func(c context.Context, ctx *app.RequestContext) {
data := utils.H{
"Title": "Sign In",
"Next": ctx.Query("next"),
}
ctx.HTML(consts.StatusOK, "sign-in", data)
})
传递query参数 next
<form method="post" action="/auth/login?next={{ .Next }}">
然后 auth_service 中的 login 函数接收 redirect next ,进行重定向。
- 鉴权
新增中间件,mniddleware 文件夹,auth.go ,以及 middleware.go,
type SessionUserIdKey string
const SessionUserId SessionUserIdKey ="user_id"
func GlobalAuth() app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
s := sessions.Default(c)
ctx = context.WithValue(ctx, frontendUtils.SessionUserId, s.Get("user_id"))
c.Next(ctx)
}
}
func Auth() app.HandlerFunc {
return func(ctx context.Context, c *app.RequestContext) {
s := sessions.Default(c)
userId := s.Get("user_id")
if userId == nil {
c.Redirect(consts.StatusFound, []byte("/sign-in?next="+c.FullPath()))
c.Abort()
return
}
c.Next(ctx)
}
}
func Register(h *server.Hertz){
h.Use(GlobalAuth())
}
然后 main,.go 函数中要记得添加路由中间件:
middleware.Register(h)
- rpc 客户端代码
rpc 客户端代码存储到 rpc_gen 中, 如 user 代码生成到此,
服务端代码生成到 app/user 中,
idl 生成代码:
syntax = "proto3";
package user;
option go_package = "/user";
message RegisterReq {
string email = 1;
string password = 2;
string password_confirm = 3;
}
message RegisterResp {
int32 user_id = 1;
}
message LoginReq {
string email = 1;
string password = 2;
}
message LoginResp {
int32 user_id = 1;
}
service UserService {
rpc Register (RegisterReq) returns (RegisterResp) {}
rpc Login (LoginReq) returns (LoginResp) {}
}
使用 make 命令进行 生成:
.PHONY: gen-user
gen-user:
@cd rpc_gen && cwgo client --type RPC --service user --module ${ROOT_MOD}/rpc_gen -I ../idl --idl ../idl/user.proto
@cd app/user && cwgo server --type RPC --service user --module ${ROOT_MOD}/app/user --pass "-use ${ROOT_MOD}/rpc_gen/kitex_gen" -I ../../idl --idl ../../idl/user.proto
这里记得 go mod tidy 会不成功,如果没有远程仓库的话,
所以记得在 go.mod 中添加。
replace github.com/yehan5555/gomallWSL/rpc_gen => ../../rpc_gen
定义用户模型:
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;type:varchar(255) not null"`
PasswordHashed string `gorm:"type:varchar(255) not null"`
}
func (User) TableName() string {
return "user"
}
func Create(ctx context.Context, db *gorm.DB, user *User) error {
return db.WithContext(ctx).Create(user).Error
}
func GetByEmail(ctx context.Context, db *gorm.DB, email string) (*User, error) {
var user User
err := db.WithContext(ctx).Where("email = ?", email).First(&user).Error
return &user, err
}
执行 go mod tidy
使用 consul 中间件:
// service info
opts = append(opts, server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{
ServiceName: conf.GetConf().Kitex.Service,
}))
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
klog.Fatal(err)
}
opts = append(opts, server.WithRegistry(r))
注册中心的配置需要进行改动,yaml 文件中进行改动,端口改成 8500, protobuf 版本也可以查看是否报错(可能由于版本问题会报错),对数据库mysql 配置进行改动:
func Init() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/user?charset=utf8mb4&parseTime=True&loc=Local", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
_=DB.AutoMigrate(&model.User{})
if err != nil {
panic(err)
}
}
yaml 文件也要进行改动:
mysql:
dsn: "%s:%s@tcp(%s:3306)/user?charset=utf8mb4&parseTime=True&loc=Local"
main 函数 使用 godotenv加载环境变量.
密码的加密使用 哈希加密,加密使用的库:
go get golang.org/x/crypto
然后注册函数:
func (s *RegisterService) Run(req *user.RegisterReq) (resp *user.RegisterResp, err error) {
// Finish your business logic.
if req.Email == "" || req.Password == "" || req.PasswordConfirm == "" {
return nil, errors.New("email or password is empty")
}
if req.Password != req.PasswordConfirm {
return nil, errors.New("password not match")
}
passwordHashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
newUser := &model.User{
Email: req.Email,
PasswordHashed: string(passwordHashed),
}
err = model.Create(s.ctx, mysql.DB, newUser)
if err != nil {
return nil, err
}
return &user.RegisterResp{UserId: int32(newUser.ID)}, nil
}
写入数据的操作放在model 中进行;
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;type:varchar(255) not null"`
PasswordHashed string `gorm:"type:varchar(255) not null"`
}
func (User) TableName() string {
return "user"
}
func Create(ctx context.Context, db *gorm.DB, user *User) error {
return db.WithContext(ctx).Create(user).Error
}
func GetByEmail(ctx context.Context, db *gorm.DB, email string) (*User, error) {
var user User
err := db.WithContext(ctx).Where("email = ?", email).First(&user).Error
return &user, err
}
这个可以创建表,并写入数据。
_=DB.AutoMigrate(&model.User{})
数据库的自动迁移。
用户服务的登录:
func (s *LoginService) Run(req *user.LoginReq) (resp *user.LoginResp, err error) {
// Finish your business logic.
if req.Email == "" || req.Password == "" {
return nil, errors.New("email or password is empty")
}
row, err := model.GetByEmail(s.ctx, mysql.DB, req.Email)
if err != nil {
return nil, err
}
err = bcrypt.CompareHashAndPassword([]byte(row.PasswordHashed), []byte(req.Password))
if err != nil {
return nil, err
}
resp = &user.LoginResp{
UserId: int32(row.ID),
}
return resp, nil
}
对接服务发现:
新建 infra/rpc:
编写 client.go 文件
var(
UserClient userservice.Client
once sync.Once
)
func InitClient() {
once.Do(func() {
initUserClient()
})
}
func initUserClient() {
r, err := consul.NewConsulResolver(conf.GetConf().Hertz.RegistryAddr)
frontendUtils.MustHandleError(err)
UserClient,err=userservice.NewClient("user",client.WithResolver(r))
frontendUtils.MustHandleError(err)
}
main 函数进行初始化:
rpc.Init()
然后前端即可使用 服务来对接 用户服务,互获取用户数据。
func (h *RegisterService) Run(req *auth.RegisterReq) (resp *common.Empty, err error) {
//defer func() {
// hlog.CtxInfof(h.Context, "req = %+v", req)
// hlog.CtxInfof(h.Context, "resp = %+v", resp)
//}()
// todo edit your code
userResp, err := rpc.UserClient.Register(h.Context, &user.RegisterReq{
Email: req.Email,
Password: req.Password,
PasswordConfirm: req.PasswordConfirm,
})
session := sessions.Default(h.RequestContext)
session.Set("user_id", userResp.UserId)
err = session.Save()
if err != nil {
return nil, err
}
return
}
继续优化
utils 中对错误判断函数进行包装:
func MustHandleError(err error) {
if err != nil {
hlog.Fatal(err)
}
}
sessiongkey 也可以通过 utils 进行定义 user_id:
type SessionUserIdKey string
const SessionUserId SessionUserIdKey = "user_id"
获取 user_id 的函数也可以抽离;
func GetUserIdFromCtx(ctx context.Context) int32 {
userId := ctx.Value(SessionUserId)
if userId == nil {
return 0
}
return userId.(int32)
}