第十章——使用 gRPC 拦截器通过 JWT 进行授权
- 实现一个服务器拦截器来授权使用 JSON Web 令牌 (JWT) 访问我们的 gRPC API。使用这个拦截器,我们将确保只有具有某些特定角色的用户才能调用我们服务器上的特定 API。
- 然后,我们将实现一个客户端拦截器来登录用户并将 JWT 附加到请求中,然后再调用 gRPC API。
10.1、一个简单的服务器拦截器
- 拦截器有两种类型:一种是用于一元RPC,另一种用于流RPC
10.1.1、一元拦截器
- 重构cmd/server/main.go里的部分代码
- 在main函数中的
grpc.NewServer()
函数中,让我们添加一个新grpc.UnaryInterceptor()
选项。它期望一元服务器拦截器功能作为输入。- 在unaryInterceptor函数中,我们只写了一个简单的日志,上面写着“一元拦截器”以及被调用的 RPC 的完整方法名称。
- 然后我们使用原始上下文和请求调用实际的处理程序,并返回其结果。
func unaryInterceptor(ctx context.Context, req interface{
}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{
}, error) {
log.Println("--> unary interceptor: ", info.FullMethod)return handler(ctx, req)
}func main() {
...grpcServer := grpc.NewServer(grpc.UnaryInterceptor(unaryInterceptor),)...
}
10.1.2、流拦截器
- 添加
grpc.StreamInterceptor()
选项。 - 按照定义获取函数签名。
- 将其复制并粘贴到
server/main.go
文件中。 - 将该函数传递给流拦截器选项。
- 使用完整的 RPC 方法名称编写日志。
- 这次将使用原始服务器和流参数调用处理程序。
func streamInterceptor(srv interface{
}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Println("--> stream interceptor: ", info.FullMethod)return handler(srv, stream)
}
10.1.3、运行客户端与服务端测试拦截器
- 启动服务端再启动客户端,运行评价接口
- 服务器日志中看到的,一元拦截器被调用了 3 次,用于创建笔记本电脑 RPC。
- 之后客户端按y使用流,在服务器端可以看到流拦截器被调用一次。
10.2、使用JWT访问令牌
- 扩展其功能以验证和授权用户请求。
- 为此,我们需要将用户添加到我们的系统,并向登录用户添加服务并返回 JWT 访问令牌。
10.2.1、定义用户结构
- 在service目录下创建user.go文件
- 定义一个
User
结构体。它将包含 三个属性:username
、hashed_password
和role
type User struct {
Username stringHashedPassword stringRole string
}
- 定义一个
NewUser()
函数来创建一个新用户,它接受用户名、密码和角色作为输入,并返回一个User
对象和一个错误- 使用函数bcrypt将密码明文处理成哈希密文,命令:
go get golang.org/x/crypto/bcrypt
- 使用函数bcrypt将密码明文处理成哈希密文,命令:
func NewUser(username string, password string, role string) (*User, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)if err != nil {
return nil, fmt.Errorf("cannot hash password: %w", err)}user := &User{
Username: username,HashedPassword: string(hashedPassword),Role: role,}return user, nil
}
- 定义一个方法
IsCorrectPassword
来检查给定的密码是否正确- 调用
bcrypt.CompareHashAndPassword()
函数,传入用户的哈希密码和给定的明文密码。函数返回true则证明一致否则不一致
- 调用
func (user *User) IsCorrectPassword(password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password))return err == nil
}
- 再添加函数用于克隆用户,后面将用户存储时使用
func (user *User) Clone() *User {
return &User{
Username: user.Username,HashedPassword: user.HashedPassword,Role: user.Role,}
}
10.2.2、定义用户存储
- 创建
service/user_store.go
文件 - 定义一个
UserStore
接口,它将有两个功能:- 一种将用户保存到商店的功能。
- 另一个功能是通过用户名在商店中查找用户。
type UserStore interface {
Save(user *User) errorFind(username string) (*User, error)
}
- 定义一个
InMemoryUserStore
结构体来实现接口- 它有一个互斥锁来控制并发访问
- 一个map存储用户
type InMemoryUserStore struct {
mutex sync.RWMutexusers map[string]*User
}
- 编写一个函数来构建一个新的内存用户存储,并在其中初始化用户映射
func NewInMemoryUserStore() *InMemoryUserStore {
return &InMemoryUserStore{
users: make(map[string]*User),}
}
- 实现Save函数,首先我们获取写锁。然后检查是否已经存在具有相同用户名的用户,没有则将用户克隆后放入map数组中
func (store *InMemoryUserStore) Save(user *User) error {
store.mutex.Lock()defer store.mutex.Unlock()if store.users[user.Username] != nil {
return fmt.Errorf("ErrAlreadyExists")}store.users[user.Username] = user.Clone()return nil
}
- 实现Find函数,首先获得一个读锁。然后我们通过用户名从map中获取用户
func (store *InMemoryUserStore) Find(username string) (*User, error) {
store.mutex.RLock()defer store.mutex.RUnlock()user := store.users[username]if user == nil {
return nil, nil}return user.Clone(), nil
}
10.2.3、实现一个 JWT 管理器来为用户生成和验证访问令牌
- 创建
service/jwt_manager.go
文件 - 定义一个JWTManager 结构体,包含两个字段:1、用于签名和验证访问令牌的密钥;2、以及令牌的有效期限。
type JWTManager struct {
secretKey stringtokenDuration time.Duration
}func NewJWTManager(secretKey string, tokenDuration time.Duration) *JWTManager {
return &JWTManager{
secretKey, tokenDuration}
}
- 安装jwt-go 库,命令:
go get github.com/dgrijalva/jwt-go
- 定义一个
UserClaims
结构体。它将包含 JWTStandardClaims
作为复合字段。
type UserClaims struct {
jwt.StandardClaimsUsername string `json:"username"`Role string `json:"role"`
}
- 编写一个函数
Generate
来为特定用户生成并签署一个新的访问令牌- 首先创建一个新的
UserClaims
对象 - 将令牌持续时间添加到当前时间并将其转换为 Unix 时间
- 然后我们设置用户的用户名和角色
- 调用
jwt.NewWithClaims()
函数来生成一个令牌对象,为简单起见,这里使用HS256
. - 最后也是最重要的一步是使用您的密钥对生成的令牌进行签名
- 首先创建一个新的
func (manager *JWTManager) Generate(user *User) (string, error) {
claims := UserClaims{
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(manager.tokenDuration).Unix(),},Username: user.Username,Role: user.Role,}token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)return token.SignedString([]byte(manager.secretKey))
}
- 添加另一个函数来验证访问令牌
- 调用
jwt.ParseWithClaims()
,传入访问令牌,一个空的用户声明和一个自定义键函数。在这个函数中,检查令牌的签名方法以确保它与我们的服务器使用的算法匹配非常重要,在我们的例子中是 HMAC。 - 从令牌中获取声明并将其转换为
UserClaims
对象
- 调用
func (manager *JWTManager) Verify(accessToken string) (*UserClaims, error) {
token, err := jwt.ParseWithClaims(accessToken,&UserClaims{
},func(token *jwt.Token) (interface{
}, error) {
_, ok := token.Method.(*jwt.SigningMethodHMAC)if !ok {
return nil, fmt.Errorf("unexpected token signing method")}return []byte(manager.secretKey), nil},)if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)}claims, ok := token.Claims.(*UserClaims)if !ok {
return nil, fmt.Errorf("invalid token claims")}return claims, nil
}
10.3、实现 Auth 服务服务器
- 创建一个新
proto/auth_service.proto
文件 - 定义了一个
LoginRequest
包含 2 个字段的消息:一个 stringusername
和一个 stringpassword
。然后是LoginResponse
一条只有 1 个字段的消息:access_token
. - 我们定义一个新的
AuthService
.
message LoginRequest {string username = 1;string password = 2;
}message LoginResponse { string access_token = 1; }service AuthService {rpc Login(LoginRequest) returns (LoginResponse) {};
}
- 运行命令:
make gen
生成go代码 - 创建一个新
service/auth_server.go
文件来实现这个新服务 - 定义一个结构体
AuthServer
type AuthServer struct {
pb.UnimplementedAuthServiceServeruserStore UserStorejwtManager *JWTManager
}func NewAuthServer(userStore UserStore, jwtManager *JWTManager) *AuthServer {
return &AuthServer{
userStore: userStore,jwtManager: jwtManager,}
}
- 实现在proto中定义的Login服务
- 调用
userStore.Find()
通过用户名查找用户 - 找到用户并且密码正确,我们调用
jwtManager.Generate()
生成一个新的访问令牌 - 使用生成的访问令牌创建一个新的登录响应对象,并将其返回给客户端
- 调用
func (server *AuthServer) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
user, err := server.userStore.Find(req.GetUsername())if err != nil {
return nil, status.Errorf(codes.Internal, "cannot find user: %v", err)}if user == nil || !user.IsCorrectPassword(req.GetPassword()) {
return nil, status.Errorf(codes.NotFound, "incorrect username/password")}token, err := server.jwtManager.Generate(user)if err != nil {
return nil, status.Errorf(codes.Internal, "cannot generate access token")}res := &pb.LoginResponse{
AccessToken: token}return res, nil
}
10.4、将身份验证服务添加到 gRPC 服务器
- 打开
cmd/server/main.go
文件 - 定义常量
const (secretKey = "secret"tokenDuration = 15 * time.Minute
)
- 重构main函数
func main() {
laptopStore := service.NewInMemoryLaptopStore()imageStore := service.NewDiskImageStore("img")ratingStore := service.NewInMemoryRatingStore()laptopServer := service.NewLaptopServer(laptopStore, imageStore, ratingStore)//userStore := service.NewInMemoryUserStore()jwtManager := service.NewJWTManager(secretKey, tokenDuration)authServer := service.NewAuthServer(userStore, jwtManager)//grpcServer := grpc.NewServer(grpc.UnaryInterceptor(unaryInterceptor),grpc.StreamInterceptor(streamInterceptor),)pb.RegisterLaptopServiceServer(grpcServer, laptopServer)//pb.RegisterAuthServiceServer(grpcServer, authServer)//reflection.Register(grpcServer)listener, _ := net.Listen("tcp", ":8888")grpcServer.Serve(listener)
}
- 测试新的登录 API,我们必须添加一些种子用户。我将编写一个函数来创建一个给定用户名、密码和角色的用户,并将其保存到用户存储中
func createUser(userStore service.UserStore, username, password, role string) error {
user, err := service.NewUser(username, password, role)if err != nil {
return err}return userStore.Save(user)
}
- 在
seedUsers()
函数中,我调用该createUser()
函数 2 次以创建 1 个管理员用户和 1 个普通用户。假设他们有相同的secret
密码。
func seedUsers(userStore service.UserStore) error {
err := createUser(userStore, "admin1", "secret", "admin")if err != nil {
return err}return createUser(userStore, "user1", "secret", "user")
}
- 主函数中,我们在创建用户存储后立即调用 seedUsers()。
func main() {
userStore := service.NewInMemoryUserStore()err := seedUsers(userStore)if err != nil {
log.Fatal("cannot seed users: ", err)}jwtManager := service.NewJWTManager(secretKey, tokenDuration)authServer := service.NewAuthServer(userStore, jwtManager)laptopStore := service.NewInMemoryLaptopStore()imageStore := service.NewDiskImageStore("img")ratingStore := service.NewInMemoryRatingStore()laptopServer := service.NewLaptopServer(laptopStore, imageStore, ratingStore)grpcServer := grpc.NewServer(grpc.UnaryInterceptor(unaryInterceptor),grpc.StreamInterceptor(streamInterceptor),)pb.RegisterLaptopServiceServer(grpcServer, laptopServer)pb.RegisterAuthServiceServer(grpcServer, authServer)reflection.Register(grpcServer)listener, _ := net.Listen("tcp", ":8888")grpcServer.Serve(listener)
}
10.5、使用 Evans CLI 尝试身份验证服务
- 启动客户端,命令:
make server
- 新开终端,使用命令:
evans -r -p 8888
- 选择服务,命令:
service AuthService
- 调用login方法,命令:
call Login
- 输入正确的用户名以及密码:admin1、secret
- 选择服务,命令:
10.6、实现服务器的身份验证拦截器
- 创建一个新
service/auth_interceptor.go
文件 - 定义一个新结构
AuthInterceptor
type AuthInterceptor struct {
jwtManager *JWTManageraccessibleRoles map[string][]string
}
- 编写一个
NewAuthInterceptor()
函数来构建并返回一个新的身份验证拦截器对象
func NewAuthInterceptor(jwtManager *JWTManager, accessibleRoles map[string][]string) *AuthInterceptor {
return &AuthInterceptor{
jwtManager, accessibleRoles}
}
- 删除掉cmd/server/main.go文件里的unaryInterceptor函数和
- 在
service/auth_interceptor.go
文件中实现新的一元拦截函数
func (interceptor *AuthInterceptor) Unary() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{
}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{
}, error) {
log.Println("--> unary interceptor: ", info.FullMethod)// TODO: implement authorizationreturn handler(ctx, req)}
}
- 添加一个新
Stream()
方法,该方法将创建并返回一个 gRPC 流服务器拦截器函数
func (interceptor *AuthInterceptor) Stream() grpc.StreamServerInterceptor {
return func(srv interface{
}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Println("--> stream interceptor: ", info.FullMethod)// TODO: implement authorizationreturn handler(srv, stream)}
}
- 在
cmd/server/main.go
文件中,我们必须使用 jwt 管理器和可访问角色的映射创建一个新的拦截器对象。在grpc.NewServer()
函数中,我们可以传入interceptor.Unary()
andinterceptor.Stream()
。
func accessibleRoles() map[string][]string {
const laptopServicePath = "/techschool.pcbook.LaptopService/"return map[string][]string{
laptopServicePath + "CreateLaptop": {
"admin"},laptopServicePath + "UploadImage": {
"admin"},laptopServicePath + "RateLaptop": {
"admin", "user"},}
}func main() {
...interceptor := service.NewAuthInterceptor(jwtManager, accessibleRoles())grpcServer := grpc.NewServer(grpc.UnaryInterceptor(interceptor.Unary()),grpc.StreamInterceptor(interceptor.Stream()),)...
}
- 定义一个新
authorize()
函数,它将以上下文和方法作为输入,如果请求未经授权,将返回错误。
func (interceptor *AuthInterceptor) authorize(ctx context.Context, method string) error {
// TODO: implement this
}
- 在
Unary()
函数和Stream()函数中,我们使用interceptor.authorize()
函数
func (interceptor *AuthInterceptor) Unary() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{
}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{
}, error) {
log.Println("--> unary interceptor: ", info.FullMethod)err := interceptor.authorize(ctx, info.FullMethod)if err != nil {
return nil, err}return handler(ctx, req)}
}func (interceptor *AuthInterceptor) Stream() grpc.StreamServerInterceptor {
return func(srv interface{
}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Println("--> stream interceptor: ", info.FullMethod)err := interceptor.authorize(stream.Context(), info.FullMethod)if err != nil {
return err}return handler(srv, stream)}
}
- 实现这个
authorize()
功能- 得到可以访问目标 RPC 方法的角色列表
- 调用
metadata.FromIncomingContext()
以获取请求的元数据 - 从
authorization
元数据键中获取值 - 访问令牌应存储在值的第一个元素中。然后我们调用
jwtManager.Verify()
以验证令牌并取回声明。 - 遍历可访问的角色以检查用户的角色是否可以访问此 RPC。如果在列表中找到用户的角色,我们只需返回
nil
。如果没有,我们返回PermissionDenied
状态码,以及一条消息说用户没有访问这个 RPC 的权限
func (interceptor *AuthInterceptor) authorize(ctx context.Context, method string) error {
accessibleRoles, ok := interceptor.accessibleRoles[method]if !ok {
// everyone can accessreturn nil}md, ok := metadata.FromIncomingContext(ctx)if !ok {
return status.Errorf(codes.Unauthenticated, "metadata is not provided")}values := md["authorization"]if len(values) == 0 {
return status.Errorf(codes.Unauthenticated, "authorization token is not provided")}accessToken := values[0]claims, err := interceptor.jwtManager.Verify(accessToken)if err != nil {
return status.Errorf(codes.Unauthenticated, "access token is invalid: %v", err)}for _, role := range accessibleRoles {
if role == claims.Role {
return nil}}return status.Error(codes.PermissionDenied, "no permission to access this RPC")
}
10.7、实现 Auth 服务客户端
- 在pcbook/client目录下创建
auth_client.go
文件 - 定义
AuthClient
调用身份验证服务的结构
type AuthClient struct {
service pb.AuthServiceClientusername stringpassword string
}
- 定义一个函数来创建和返回一个新
AuthClient
对象
func NewAuthClient(cc *grpc.ClientConn, username string, password string) *AuthClient {
service := pb.NewAuthServiceClient(cc)return &AuthClient{
service, username, password}
}
- 编写一个
Login()
函数来调用 Login RPC 来获取访问令牌
func (client *AuthClient) Login() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()req := &pb.LoginRequest{
Username: client.username,Password: client.password,}res, err := client.service.Login(ctx, req)if err != nil {
return "", err}return res.GetAccessToken(), nil
}
- 在client目录下创建文件
client/auth_interceptor.go
,在调用服务器之前,我们将拦截所有 gRPC 请求并为它们附加访问令牌 - 定义一个
AuthInterceptor
结构
type AuthInterceptor struct {
authClient *AuthClientauthMethods map[string]boolaccessToken string
}
- 在
NewAuthInterceptor()
函数中,除了 auth 客户端和 auth 方法之外,我们还需要一个刷新令牌持续时间参数。它会告诉我们应该多久调用一次登录 API 来获取新令牌。- 用一个内部函数:
scheduleRefreshToken()
调度刷新访问令牌并传入刷新持续时间
- 用一个内部函数:
func NewAuthInterceptor(authClient *AuthClient, authMethods map[string]bool, refreshDuration time.Duration) (*AuthInterceptor, error) {
interceptor := &AuthInterceptor{
authClient: authClient,authMethods: authMethods,}err := interceptor.scheduleRefreshToken(refreshDuration)if err != nil {
return nil, err}return interceptor, nil
}
- 实现这个
scheduleRefreshToken()
功能- 需要一个函数来刷新令牌而不进行调度。在这个函数中,我们只是使用 auth 客户端来登录用户。
- 返回令牌后,我们只需将其存储在
interceptor.accessToken
字段中。我们在这里写一个简单的日志,稍后观察,最后返回nil
。
func (interceptor *AuthInterceptor) refreshToken() error {
accessToken, err := interceptor.authClient.Login()if err != nil {
return err}interceptor.accessToken = accessTokenlog.Printf("token refreshed: %v", accessToken)return nil
}func (interceptor *AuthInterceptor) scheduleRefreshToken(refreshDuration time.Duration) error {
err := interceptor.refreshToken()if err != nil {
return err}go func() {
wait := refreshDurationfor {
time.Sleep(wait)err := interceptor.refreshToken()if err != nil {
wait = time.Second} else {
wait = refreshDuration}}}()return nil
}
- 定义一个
Unary()
函数来返回一个 gRPC 一元客户端拦截器。- 在这个拦截器函数中,检查此方法是否需要身份验证
- 需要则必须在调用实际的 RPC 之前将访问令牌附加到上下文
func (interceptor *AuthInterceptor) Unary() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{
}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
log.Printf("--> unary interceptor: %s", method)if interceptor.authMethods[method] {
return invoker(interceptor.attachToken(ctx), method, req, reply, cc, opts...)}return invoker(ctx, method, req, reply, cc, opts...)}
}
- 定义一个新
attachToken()
函数来将令牌附加到输入上下文并返回结果。- 使用
metadata.AppendToOutgoingContext()
, 将输入上下文连同授权密钥和访问令牌值一起传递。
- 使用
func (interceptor *AuthInterceptor) attachToken(ctx context.Context) context.Context {
return metadata.AppendToOutgoingContext(ctx, "authorization", interceptor.accessToken)
}
- 实现流拦截器
func (interceptor *AuthInterceptor) Stream() grpc.StreamClientInterceptor {
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
log.Printf("--> stream interceptor: %s", method)if interceptor.authMethods[method] {
return streamer(interceptor.attachToken(ctx), desc, cc, method, opts...)}return streamer(ctx, desc, cc, method, opts...)}
}
- 重构cmd/client/main.go中main函数
- 为 auth 客户端创建一个单独的连接,因为它将用于创建一个 auth 拦截器,该拦截器将用于为笔记本电脑客户端创建另一个连接
- 连接名称更改为
cc1
,并用它创建一个新的身份验证客户端 - 用 auth 客户端创建一个新的拦截器
- 编写一个函数
authMethods()
来定义需要身份验证的方法列表。
const (username = "admin1"password = "secret"refreshDuration = 30 * time.Second
)func authMethods() map[string]bool {
const laptopServicePath = "/techschool.pcbook.LaptopService/"return map[string]bool{
laptopServicePath + "CreateLaptop": true,laptopServicePath + "UploadImage": true,laptopServicePath + "RateLaptop": true,}
}func main() {
cc1, _ := grpc.Dial("localhost:8888", grpc.WithInsecure())authClient := client.NewAuthClient(cc1, username, password)interceptor, _ := client.NewAuthInterceptor(authClient, authMethods(), refreshDuration)
}
- 将拨号服务器以创建另一个连接。但这一次,我们还添加了 2 个拨号选项:一元拦截器和流拦截器。
func main() {
cc1, _ := grpc.Dial("localhost:8888", grpc.WithInsecure())authClient := client.NewAuthClient(cc1, username, password)interceptor, _ := client.NewAuthInterceptor(authClient, authMethods(), refreshDuration)cc2, _ := grpc.Dial("localhost:8888",grpc.WithInsecure(),grpc.WithUnaryInterceptor(interceptor.Unary()),grpc.WithStreamInterceptor(interceptor.Stream()),)laptopClient := pb.NewLaptopServiceClient(cc2)testRateLaptop(laptopClient)
}
- 向Makefile文件中添加一句话:
.PHONY: gen clean server client test
gen:protoc --proto_path=proto --go_out=pb --go-grpc_out=pb proto/*.protoclean:rm pb/*.gotest:go test -cover -race serializer/file_test.goserver:go run cmd/server/main.goclient:go run cmd/client/main.go.PHONY: gen clean server client test
10.8、启动服务观察效果
- 启动服务端与客户端,可以看到能够正常使用
- 修改cmd/cliwnt/main.go文件中的常量,修改为:将username=“admin"
const (username = "admin"password = "secret"refreshDuration = 30 * time.Second
)
-
在启动客户端发现无法正常工作,与服务器所规定的账户不一致
grpc.WithInsecure(),
grpc.WithUnaryInterceptor(interceptor.Unary()),
grpc.WithStreamInterceptor(interceptor.Stream()),
)laptopClient := pb.NewLaptopServiceClient(cc2)
testRateLaptop(laptopClient)
}
15. 向Makefile文件中添加一句话:`.PHONY: gen clean server client test````makefile
gen:protoc --proto_path=proto --go_out=pb --go-grpc_out=pb proto/*.protoclean:rm pb/*.gotest:go test -cover -race serializer/file_test.goserver:go run cmd/server/main.goclient:go run cmd/client/main.go.PHONY: gen clean server client test
10.8、启动服务观察效果
- 启动服务端与客户端,可以看到能够正常使用
- 修改cmd/cliwnt/main.go文件中的常量,修改为:将username=“admin"
const (username = "admin"password = "secret"refreshDuration = 30 * time.Second
)
- 在启动客户端发现无法正常工作,与服务器所规定的账户不一致