From b67ae5a1e5952af7117f09647b307328fb94b523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 24 Aug 2024 14:40:42 +0000 Subject: [PATCH] add token service (#2) Co-authored-by: daniel.w Reviewed-on: https://code.30cm.net/digimon/app-cloudep-permission-server/pulls/2 --- Makefile | 4 +- go.mod | 13 + internal/config/config.go | 17 +- internal/domain/const.go | 18 ++ internal/domain/errors.go | 83 +++++ internal/domain/permission.go | 45 +++ internal/domain/redis.go | 44 +++ internal/domain/repository/error.go | 60 ++++ internal/domain/repository/token.go | 31 ++ internal/domain/usecase/opa.go | 47 +++ internal/domain/usecase/permission_tree.go | 38 +++ internal/entity/claims.go | 8 + internal/entity/token.go | 50 +++ internal/lib/metric/app.go | 30 ++ .../cancel_one_time_token_logic.go | 18 +- .../cancel_token_by_device_id_logic.go | 15 +- .../logic/tokenservice/cancel_token_logic.go | 38 ++- .../logic/tokenservice/cancel_tokens_logic.go | 23 +- .../get_user_tokens_by_device_id_logic.go | 31 +- .../get_user_tokens_by_uid_logic.go | 31 +- .../tokenservice/new_one_time_token_logic.go | 43 ++- .../logic/tokenservice/new_token_logic.go | 111 ++++++- .../logic/tokenservice/refresh_token_logic.go | 80 ++++- internal/logic/tokenservice/utils_claims.go | 55 ++++ internal/logic/tokenservice/utils_jwt.go | 104 ++++++ .../tokenservice/validation_token_logic.go | 42 ++- internal/repository/token.go | 302 ++++++++++++++++++ .../tokenservice/token_service_server.go | 2 +- internal/svc/service_context.go | 33 +- 29 files changed, 1394 insertions(+), 22 deletions(-) create mode 100644 internal/domain/const.go create mode 100644 internal/domain/errors.go create mode 100644 internal/domain/permission.go create mode 100644 internal/domain/redis.go create mode 100644 internal/domain/repository/error.go create mode 100644 internal/domain/repository/token.go create mode 100644 internal/domain/usecase/opa.go create mode 100644 internal/domain/usecase/permission_tree.go create mode 100644 internal/entity/claims.go create mode 100644 internal/entity/token.go create mode 100644 internal/lib/metric/app.go create mode 100644 internal/logic/tokenservice/utils_claims.go create mode 100644 internal/logic/tokenservice/utils_jwt.go create mode 100644 internal/repository/token.go diff --git a/Makefile b/Makefile index f3eff17..30110df 100644 --- a/Makefile +++ b/Makefile @@ -70,5 +70,5 @@ mock-gen: # 建立 mock 資料 .PHONY: migrate-database migrate-database: - migrate -source file://generate/database/mysql -database 'mysql://root:yytt@tcp(127.0.0.1:3306)/digimon_member' up - migrate -source file://generate/database/seeders -database 'mysql://root:yytt@tcp(127.0.0.1:3306)/digimon_member' up + migrate -source file://generate/database/mysql -database 'mysql://root:yytt@tcp(127.0.0.1:3306)/digimon_permission' up + migrate -source file://generate/database/seeders -database 'mysql://root:yytt@tcp(127.0.0.1:3306)/digimon_permission' up diff --git a/go.mod b/go.mod index e63a795..d1810d9 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,18 @@ module app-cloudep-permission-server go 1.22.3 require ( + code.30cm.net/digimon/library-go/errors v1.0.1 + code.30cm.net/digimon/library-go/validator v1.0.0 + code.30cm.net/wanderland/library-go/errors v1.0.1 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/zeromicro/go-zero v1.7.0 + go.uber.org/mock v0.4.0 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -18,11 +24,16 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/fatih/color v1.17.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.22.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -33,6 +44,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -65,6 +77,7 @@ require ( go.uber.org/automaxprocs v1.5.3 // indirect go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/sys v0.22.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index c1f85b9..95a9aab 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,22 @@ package config -import "github.com/zeromicro/go-zero/zrpc" +import ( + "time" + + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/zrpc" +) type Config struct { zrpc.RpcServerConf + RedisCluster redis.RedisConf + Token struct { + RefreshExpires time.Duration + Expired time.Duration + Secret string + } + // 加上DB結構體 + DB struct { + DsnString string + } } diff --git a/internal/domain/const.go b/internal/domain/const.go new file mode 100644 index 0000000..82ac1b8 --- /dev/null +++ b/internal/domain/const.go @@ -0,0 +1,18 @@ +package domain + +type GrantType string + +const ( + PasswordCredentials GrantType = "password" + ClientCredentials GrantType = "client_credentials" + Refreshing GrantType = "refresh_token" +) + +const ( + // DefaultRole 預設role + DefaultRole = "user" +) + +const ( + TokenTypeBearer = "Bearer" +) diff --git a/internal/domain/errors.go b/internal/domain/errors.go new file mode 100644 index 0000000..aef56d1 --- /dev/null +++ b/internal/domain/errors.go @@ -0,0 +1,83 @@ +package domain + +import ( + mts "app-cloudep-permission-server/internal/lib/metric" + + ers "code.30cm.net/wanderland/library-go/errors" + "code.30cm.net/wanderland/library-go/errors/code" +) + +// 12 represents Scope +// 100 represents Category +// 9 represents Detail error code +// full code 12009 只會有 系統以及錯誤碼,category 是給系統判定用的 +// 目前 Scope 以及分類要系統共用,係向的錯誤各自服務實作就好 + +// token error 方面 +const ( + TokenUnexpectedSigningErrorCode = iota + 1 + TokenValidateErrorCode + TokenClaimErrorCode +) + +const ( + RedisDelErrorCode = iota + 20 + RedisPipLineErrorCode + RedisErrorCode +) + +const ( + PermissionNotFoundCode = iota + 30 + PermissionGetDataErrorCode +) + +// TokenUnexpectedSigningErr 30001 Token 簽名錯誤 +func TokenUnexpectedSigningErr(msg string) *ers.Err { + mts.AppErrorMetrics.AddFailure("token", "token_unexpected_sign") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenUnexpectedSigningErrorCode, msg) +} + +// TokenTokenValidateErr 30002 Token 驗證錯誤 +func TokenTokenValidateErr(msg string) *ers.Err { + mts.AppErrorMetrics.AddFailure("token", "token_validate_ilegal") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenValidateErrorCode, msg) +} + +// TokenClaimError 30003 Token 驗證錯誤 +func TokenClaimError(msg string) *ers.Err { + mts.AppErrorMetrics.AddFailure("token", "token_claim_error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenClaimErrorCode, msg) +} + +// RedisDelError 30020 Redis 刪除錯誤 +func RedisDelError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "del_error") + return ers.NewErr(code.CloudEPPermission, code.CatDB, RedisDelErrorCode, msg) +} + +// RedisPipLineError 30021 Redis PipLine 錯誤 +func RedisPipLineError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "pip_line_error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, RedisPipLineErrorCode, msg) +} + +// RedisError 30022 Redis 錯誤 +func RedisError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, RedisErrorCode, msg) +} + +// PermissionNotFoundError 30030 權限錯誤 +func PermissionNotFoundError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + return ers.NewErr(code.CloudEPPermission, code.Forbidden, PermissionNotFoundCode, msg) +} + +// PermissionGetDataError 30031 解析權限時錯誤 +func PermissionGetDataError(msg string) *ers.Err { + // 看需要建立哪些 Metrics + return ers.NewErr(code.CloudEPPermission, code.InvalidFormat, PermissionGetDataErrorCode, msg) +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go new file mode 100644 index 0000000..c4cac51 --- /dev/null +++ b/internal/domain/permission.go @@ -0,0 +1,45 @@ +package domain + +type PermissionType int8 + +const ( + PermissionTypeBackendUser PermissionType = iota + 1 + PermissionTypeFrontendUser +) + +type PermissionTypeCode string + +const ( + PermissionTypeBackCode PermissionTypeCode = "back" + PermissionTypeFrontCode PermissionTypeCode = "front" +) + +var permissionMap = map[int64]PermissionTypeCode{ + 1: PermissionTypeFrontCode, + 2: PermissionTypeBackCode, +} + +func ToPermissionTypeCode(code int64) (PermissionTypeCode, bool) { + result, ok := permissionMap[code] + if !ok { + return "", false + } + + return result, true +} + +func (t *PermissionTypeCode) ToString() string { + return string(*t) +} + +type PermissionStatus string +type Permissions map[string]PermissionStatus + +const ( + PermissionStatusOpenCode PermissionStatus = "open" + PermissionStatusCloseCode PermissionStatus = "close" +) + +const ( + AdminRoleID = "GodDog!@#" +) diff --git a/internal/domain/redis.go b/internal/domain/redis.go new file mode 100644 index 0000000..2e31a91 --- /dev/null +++ b/internal/domain/redis.go @@ -0,0 +1,44 @@ +package domain + +import "strings" + +const ( + TicketKeyPrefix = "tic/" +) + +const ( + ClientDataKey = "permission:clients" +) + +type RedisKey string + +const ( + AccessTokenRedisKey RedisKey = "access_token" + RefreshTokenRedisKey RedisKey = "refresh_token" + DeviceTokenRedisKey RedisKey = "device_token" + UIDTokenRedisKey RedisKey = "uid_token" + TicketRedisKey RedisKey = "ticket" + DeviceUIDRedisKey RedisKey = "device_uid" +) + +func (key RedisKey) ToString() string { + return "permission:" + string(key) +} + +func (key RedisKey) With(s ...string) RedisKey { + parts := append([]string{string(key)}, s...) + + return RedisKey(strings.Join(parts, ":")) +} + +func GetAccessTokenRedisKey(id string) string { + return AccessTokenRedisKey.With(id).ToString() +} + +func GetUIDTokenRedisKey(uid string) string { + return UIDTokenRedisKey.With(uid).ToString() +} + +func GetTicketRedisKey(ticket string) string { + return TicketRedisKey.With(ticket).ToString() +} diff --git a/internal/domain/repository/error.go b/internal/domain/repository/error.go new file mode 100644 index 0000000..cf590af --- /dev/null +++ b/internal/domain/repository/error.go @@ -0,0 +1,60 @@ +package repository + +import ( + mts "app-cloudep-permission-server/internal/lib/metric" + + ers "code.30cm.net/digimon/library-go/errors" + "code.30cm.net/digimon/library-go/errors/code" +) + +// token error 方面 +const ( + TokenUnexpectedSigningErrorCode = iota + 1 + TokenValidateErrorCode + TokenClaimErrorCode +) + +const ( + RedisDelErrorCode = iota + 20 + RedisPipLineErrorCode + RedisErrorCode +) + +// TokenUnexpectedSigningErr 30001 Token 簽名錯誤 +func TokenUnexpectedSigningErr(msg string) *ers.LibError { + mts.AppErrorMetrics.AddFailure("token", "token_unexpected_sign") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenUnexpectedSigningErrorCode, msg) +} + +// TokenTokenValidateErr 30002 Token 驗證錯誤 +func TokenTokenValidateErr(msg string) *ers.LibError { + mts.AppErrorMetrics.AddFailure("token", "token_validate_ilegal") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenValidateErrorCode, msg) +} + +// TokenClaimError 30003 Token 驗證錯誤 +func TokenClaimError(msg string) *ers.LibError { + mts.AppErrorMetrics.AddFailure("token", "token_claim_error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, TokenClaimErrorCode, msg) +} + +// RedisDelError 30020 Redis 刪除錯誤 +func RedisDelError(msg string) *ers.LibError { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "del_error") + return ers.NewErr(code.CloudEPPermission, code.CatDB, RedisDelErrorCode, msg) +} + +// RedisPipLineError 30021 Redis PipLine 錯誤 +func RedisPipLineError(msg string) *ers.LibError { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "pip_line_error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, RedisPipLineErrorCode, msg) +} + +// RedisError 30022 Redis 錯誤 +func RedisError(msg string) *ers.LibError { + // 看需要建立哪些 Metrics + mts.AppErrorMetrics.AddFailure("redis", "error") + return ers.NewErr(code.CloudEPPermission, code.CatInput, RedisErrorCode, msg) +} diff --git a/internal/domain/repository/token.go b/internal/domain/repository/token.go new file mode 100644 index 0000000..0d33421 --- /dev/null +++ b/internal/domain/repository/token.go @@ -0,0 +1,31 @@ +package repository + +import ( + "app-cloudep-permission-server/internal/entity" + "context" + "time" +) + +// TokenRepository token 的 redis 操作 +type TokenRepository interface { + // Create 建立Token + Create(ctx context.Context, token entity.Token) error + // CreateOneTimeToken 建立臨時 Token + CreateOneTimeToken(ctx context.Context, key string, ticket entity.Ticket, dt time.Duration) error + GetAccessTokenByByOneTimeToken(ctx context.Context, oneTimeToken string) (entity.Token, error) + GetAccessTokenByID(ctx context.Context, id string) (entity.Token, error) + GetAccessTokensByUID(ctx context.Context, uid string) ([]entity.Token, error) + GetAccessTokenCountByUID(uid string) (int, error) + GetAccessTokensByDeviceID(ctx context.Context, deviceID string) ([]entity.Token, error) + GetAccessTokenCountByDeviceID(deviceID string) (int, error) + Delete(ctx context.Context, token entity.Token) error + DeleteOneTimeToken(ctx context.Context, ids []string, tokens []entity.Token) error + DeleteAccessTokenByID(ctx context.Context, ids []string) error + DeleteAccessTokensByUID(ctx context.Context, uid string) error + DeleteAccessTokensByDeviceID(ctx context.Context, deviceID string) error +} + +type DeviceToken struct { + DeviceID string + TokenID string +} diff --git a/internal/domain/usecase/opa.go b/internal/domain/usecase/opa.go new file mode 100644 index 0000000..1a1f3a1 --- /dev/null +++ b/internal/domain/usecase/opa.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "context" +) + +type OpaUseCase interface { + // CheckRBACPermission 確認有無權限 + CheckRBACPermission(ctx context.Context, req CheckReq) (CheckOPAResp, error) + // LoadPolicy 將 Policy 從其他地方加載到 opa 的 policy 當中 + LoadPolicy(ctx context.Context, input []Policy) error + GetPolicy(ctx context.Context) []map[string]any +} + +type CheckReq struct { + ID string + Roles []string + Path string + Method string +} + +type Grant struct { + ID string + Path string + Method string +} + +type Policy struct { + Methods []string `json:"methods"` + Name string `json:"name"` + Path string `json:"path"` + Role string `json:"role"` +} + +type RuleRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Policies []Policy `json:"policies"` + Roles []string `json:"roles"` +} + +type CheckOPAResp struct { + Allow bool `json:"allow"` + PolicyName string `json:"policy_name"` + PlainCode bool `json:"plain_code"` // 是否為明碼顯示 + Request RuleRequest `json:"request"` +} diff --git a/internal/domain/usecase/permission_tree.go b/internal/domain/usecase/permission_tree.go new file mode 100644 index 0000000..f67f436 --- /dev/null +++ b/internal/domain/usecase/permission_tree.go @@ -0,0 +1,38 @@ +package usecase + +import ( + "ark-permission/internal/domain" + "ark-permission/internal/entity" +) + +// PermissionTreeManager 定義一組操作權限樹的接口 +// 這個名稱說明它是專門負責管理和操作權限樹的管理器 +type PermissionTreeManager interface { + // AddPermission 將一個新的權限節點插入到樹中 + // key 是父節點的ID,value 是要插入的 Permission 資料 + // 此方法應該能處理節點是否存在於父節點下的情況 + AddPermission(parentID int64, permission entity.Permission) error + // FindPermissionByID 根據權限 ID 查詢樹中的某個節點 + // 如果節點存在,返回對應的 Permission 資料,否則返回 nil + FindPermissionByID(permissionID int64) (*Permission, error) + // GetAllParentPermissionIDs 根據傳入的 permissions 列表 + // 找出每個權限的完整父節點權限 ID 路徑 + // 例如,如果 B 的父權限是 A,並且給了 B 權限,則返回 A 和 B 的權限 ID + GetAllParentPermissionIDs(permissions domain.Permissions) ([]int64, error) + // GetAllParentPermissionStatuses 返回給定權限下的所有完整父節點權限狀態 + // 例如,若給 B 權限,該方法將返回所有與 B 相關的父權限的狀態 + GetAllParentPermissionStatuses(permissions domain.Permissions) (domain.Permissions, error) + // GetRolePermissionTree 根據角色權限找出所有父節點和子節點權限狀態 + // 角色權限是傳入的一個列表,該方法會根據每個角色的權限,返回所有相關的權限狀態 + GetRolePermissionTree(rolePermissions []entity.RolePermission) domain.Permissions +} + +type Permission struct { + ID int64 `json:"-"` + Name string `json:"name"` + HTTPMethod string `json:"http_method"` + HTTPPath string `json:"http_path"` + Parent *Permission `json:"-"` + Children []*Permission `json:"children"` + PathIDs []int64 `json:"-"` // full path id +} diff --git a/internal/entity/claims.go b/internal/entity/claims.go new file mode 100644 index 0000000..9271795 --- /dev/null +++ b/internal/entity/claims.go @@ -0,0 +1,8 @@ +package entity + +import "github.com/golang-jwt/jwt/v4" + +type Claims struct { + jwt.RegisteredClaims + Data interface{} `json:"data"` +} diff --git a/internal/entity/token.go b/internal/entity/token.go new file mode 100644 index 0000000..791395a --- /dev/null +++ b/internal/entity/token.go @@ -0,0 +1,50 @@ +package entity + +import "time" + +type Token struct { + ID string `json:"id"` + UID string `json:"uid"` + DeviceID string `json:"device_id"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + AccessCreateAt time.Time `json:"access_create_at"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int `json:"refresh_expires_in"` + RefreshCreateAt time.Time `json:"refresh_create_at"` +} + +func (t *Token) AccessTokenExpires() time.Duration { + return time.Duration(t.ExpiresIn) * time.Second +} + +func (t *Token) RefreshTokenExpires() time.Duration { + return time.Duration(t.RefreshExpiresIn) * time.Second +} + +func (t *Token) RefreshTokenExpiresUnix() int64 { + return time.Now().Add(t.RefreshTokenExpires()).Unix() +} + +func (t *Token) IsExpires() bool { + return t.AccessCreateAt.Add(t.AccessTokenExpires()).Before(time.Now()) +} + +func (t *Token) RedisExpiredSec() int64 { + sec := time.Unix(int64(t.ExpiresIn), 0).Sub(time.Now().UTC()) + + return int64(sec.Seconds()) +} + +func (t *Token) RedisRefreshExpiredSec() int64 { + sec := time.Unix(int64(t.RefreshExpiresIn), 0).Sub(time.Now().UTC()) + + return int64(sec.Seconds()) +} + +type UIDToken map[string]int64 + +type Ticket struct { + Data any `json:"data"` + Token Token `json:"token"` +} diff --git a/internal/lib/metric/app.go b/internal/lib/metric/app.go new file mode 100644 index 0000000..59da7ef --- /dev/null +++ b/internal/lib/metric/app.go @@ -0,0 +1,30 @@ +package metric + +import ( + "github.com/zeromicro/go-zero/core/metric" +) + +var AppErrorMetrics = NewAppErrMetrics() + +type appErrMetrics struct { + metric.CounterVec +} + +type Metrics interface { + AddFailure(source, reason string) +} + +// NewAppErrMetrics initiate metrics and register to prometheus +func NewAppErrMetrics() Metrics { + return &appErrMetrics{metric.NewCounterVec(&metric.CounterVecOpts{ + Namespace: "ark", + Subsystem: "permission", + Name: "permission_app_error_total", + Help: "App defined failure total.", + Labels: []string{"source", "reason"}, + })} +} + +func (m *appErrMetrics) AddFailure(source, reason string) { + m.Inc(source, reason) +} diff --git a/internal/logic/tokenservice/cancel_one_time_token_logic.go b/internal/logic/tokenservice/cancel_one_time_token_logic.go index 70f109a..9bb38bf 100644 --- a/internal/logic/tokenservice/cancel_one_time_token_logic.go +++ b/internal/logic/tokenservice/cancel_one_time_token_logic.go @@ -3,6 +3,8 @@ package tokenservicelogic import ( "context" + ers "code.30cm.net/digimon/library-go/errors" + "app-cloudep-permission-server/gen_result/pb/permission" "app-cloudep-permission-server/internal/svc" @@ -23,9 +25,23 @@ func NewCancelOneTimeTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } +type cancelOneTimeTokenReq struct { + Token []string `json:"token" validate:"required"` +} + // CancelOneTimeToken 取消一次性使用 func (l *CancelOneTimeTokenLogic) CancelOneTimeToken(in *permission.CancelOneTimeTokenReq) (*permission.OKResp, error) { - // todo: add your logic here and delete this line + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&cancelOneTimeTokenReq{ + Token: in.GetToken(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + + err := l.svcCtx.TokenRedisRepo.DeleteOneTimeToken(l.ctx, in.GetToken(), nil) + if err != nil { + return nil, err + } return &permission.OKResp{}, nil } diff --git a/internal/logic/tokenservice/cancel_token_by_device_id_logic.go b/internal/logic/tokenservice/cancel_token_by_device_id_logic.go index e1623fe..f24b3f2 100644 --- a/internal/logic/tokenservice/cancel_token_by_device_id_logic.go +++ b/internal/logic/tokenservice/cancel_token_by_device_id_logic.go @@ -1,6 +1,7 @@ package tokenservicelogic import ( + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -25,7 +26,19 @@ func NewCancelTokenByDeviceIdLogic(ctx context.Context, svcCtx *svc.ServiceConte // CancelTokenByDeviceId 取消 Token, 從 Device 視角出發,可以選,登出這個Device 下所有 token ,登出這個Device 下指定token func (l *CancelTokenByDeviceIdLogic) CancelTokenByDeviceId(in *permission.DoTokenByDeviceIDReq) (*permission.OKResp, error) { - // todo: add your logic here and delete this line + if err := l.svcCtx.Validate.ValidateAll(&getUserTokensByDeviceIdReq{ + DeviceID: in.GetDeviceId(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + err := l.svcCtx.TokenRedisRepo.DeleteAccessTokensByDeviceID(l.ctx, in.GetDeviceId()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.DeleteAccessTokensByDeviceID"), + logx.Field("DeviceID", in.GetDeviceId()), + ).Error(err.Error()) + return nil, err + } return &permission.OKResp{}, nil } diff --git a/internal/logic/tokenservice/cancel_token_logic.go b/internal/logic/tokenservice/cancel_token_logic.go index 7e1808b..6a57053 100644 --- a/internal/logic/tokenservice/cancel_token_logic.go +++ b/internal/logic/tokenservice/cancel_token_logic.go @@ -1,6 +1,7 @@ package tokenservicelogic import ( + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -23,9 +24,44 @@ func NewCancelTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Cance } } +type cancelTokenReq struct { + Token string `json:"token" validate:"required"` +} + // CancelToken 取消 Token,也包含他裡面的 One Time Toke func (l *CancelTokenLogic) CancelToken(in *permission.CancelTokenReq) (*permission.OKResp, error) { - // todo: add your logic here and delete this line + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&cancelTokenReq{ + Token: in.GetToken(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + + claims, err := parseClaims(in.GetToken(), l.svcCtx.Config.Token.Secret, false) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "parseClaims"), + ).Error(err.Error()) + return nil, err + } + + token, err := l.svcCtx.TokenRedisRepo.GetAccessTokenByID(l.ctx, claims.ID()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.GetByAccess"), + logx.Field("claims", claims), + ).Error(err.Error()) + return nil, err + } + + err = l.svcCtx.TokenRedisRepo.Delete(l.ctx, token) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.Delete"), + logx.Field("req", token), + ).Error(err.Error()) + return nil, err + } return &permission.OKResp{}, nil } diff --git a/internal/logic/tokenservice/cancel_tokens_logic.go b/internal/logic/tokenservice/cancel_tokens_logic.go index 3df4d22..c905f3e 100644 --- a/internal/logic/tokenservice/cancel_tokens_logic.go +++ b/internal/logic/tokenservice/cancel_tokens_logic.go @@ -1,6 +1,7 @@ package tokenservicelogic import ( + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -25,7 +26,27 @@ func NewCancelTokensLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Canc // CancelTokens 取消 Token 從UID 視角,以及 token id 視角出發, UID 登出,底下所有 Device ID 也要登出, Token ID 登出, 所有 UID + Device 都要登出 func (l *CancelTokensLogic) CancelTokens(in *permission.DoTokenByUIDReq) (*permission.OKResp, error) { - // todo: add your logic here and delete this line + if in.GetUid() != "" { + err := l.svcCtx.TokenRedisRepo.DeleteAccessTokensByUID(l.ctx, in.GetUid()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.DeleteAccessTokensByUID"), + logx.Field("uid", in.GetUid()), + ).Error(err.Error()) + return nil, ers.ResourceInsufficient(err.Error()) + } + } + + if len(in.GetIds()) > 0 { + err := l.svcCtx.TokenRedisRepo.DeleteAccessTokenByID(l.ctx, in.GetIds()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.DeleteAccessTokenByID"), + logx.Field("ids", in.GetIds()), + ).Error(err.Error()) + return nil, ers.ResourceInsufficient(err.Error()) + } + } return &permission.OKResp{}, nil } diff --git a/internal/logic/tokenservice/get_user_tokens_by_device_id_logic.go b/internal/logic/tokenservice/get_user_tokens_by_device_id_logic.go index b289f0b..bc42943 100644 --- a/internal/logic/tokenservice/get_user_tokens_by_device_id_logic.go +++ b/internal/logic/tokenservice/get_user_tokens_by_device_id_logic.go @@ -1,6 +1,8 @@ package tokenservicelogic import ( + "app-cloudep-permission-server/internal/domain" + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -23,9 +25,34 @@ func NewGetUserTokensByDeviceIdLogic(ctx context.Context, svcCtx *svc.ServiceCon } } +type getUserTokensByDeviceIdReq struct { + DeviceID string `json:"device_id" validate:"required"` +} + // GetUserTokensByDeviceId 取得目前所對應的 DeviceID 所存在的 Tokens func (l *GetUserTokensByDeviceIdLogic) GetUserTokensByDeviceId(in *permission.DoTokenByDeviceIDReq) (*permission.Tokens, error) { - // todo: add your logic here and delete this line + if err := l.svcCtx.Validate.ValidateAll(&getUserTokensByDeviceIdReq{ + DeviceID: in.GetDeviceId(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } - return &permission.Tokens{}, nil + uidTokens, err := l.svcCtx.TokenRedisRepo.GetAccessTokensByDeviceID(l.ctx, in.GetDeviceId()) + if err != nil { + return nil, err + } + + tokens := make([]*permission.TokenResp, 0, len(uidTokens)) + for _, v := range uidTokens { + tokens = append(tokens, &permission.TokenResp{ + AccessToken: v.AccessToken, + TokenType: domain.TokenTypeBearer, + ExpiresIn: int32(v.ExpiresIn), + RefreshToken: v.RefreshToken, + }) + } + + return &permission.Tokens{ + Token: tokens, + }, nil } diff --git a/internal/logic/tokenservice/get_user_tokens_by_uid_logic.go b/internal/logic/tokenservice/get_user_tokens_by_uid_logic.go index 9f6c69f..251ba08 100644 --- a/internal/logic/tokenservice/get_user_tokens_by_uid_logic.go +++ b/internal/logic/tokenservice/get_user_tokens_by_uid_logic.go @@ -1,6 +1,8 @@ package tokenservicelogic import ( + "app-cloudep-permission-server/internal/domain" + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -23,9 +25,34 @@ func NewGetUserTokensByUidLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } +type getUserTokensByUidReq struct { + UID string `json:"uid" validate:"required"` +} + // GetUserTokensByUid 取得目前所對應的 UID 所存在的 Tokens func (l *GetUserTokensByUidLogic) GetUserTokensByUid(in *permission.QueryTokenByUIDReq) (*permission.Tokens, error) { - // todo: add your logic here and delete this line + if err := l.svcCtx.Validate.ValidateAll(&getUserTokensByUidReq{ + UID: in.GetUid(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } - return &permission.Tokens{}, nil + uidTokens, err := l.svcCtx.TokenRedisRepo.GetAccessTokensByUID(l.ctx, in.GetUid()) + if err != nil { + return nil, err + } + + tokens := make([]*permission.TokenResp, 0, len(uidTokens)) + for _, v := range uidTokens { + tokens = append(tokens, &permission.TokenResp{ + AccessToken: v.AccessToken, + TokenType: domain.TokenTypeBearer, + ExpiresIn: int32(v.ExpiresIn), + RefreshToken: v.RefreshToken, + }) + } + + return &permission.Tokens{ + Token: tokens, + }, nil } diff --git a/internal/logic/tokenservice/new_one_time_token_logic.go b/internal/logic/tokenservice/new_one_time_token_logic.go index 082ea3b..d2ba695 100644 --- a/internal/logic/tokenservice/new_one_time_token_logic.go +++ b/internal/logic/tokenservice/new_one_time_token_logic.go @@ -1,7 +1,12 @@ package tokenservicelogic import ( + "app-cloudep-permission-server/internal/domain" + "app-cloudep-permission-server/internal/entity" + ers "code.30cm.net/digimon/library-go/errors" "context" + "github.com/google/uuid" + "time" "app-cloudep-permission-server/gen_result/pb/permission" "app-cloudep-permission-server/internal/svc" @@ -25,7 +30,41 @@ func NewNewOneTimeTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *N // NewOneTimeToken 建立一次性使用,例如:RefreshToken func (l *NewOneTimeTokenLogic) NewOneTimeToken(in *permission.CreateOneTimeTokenReq) (*permission.CreateOneTimeTokenResp, error) { - // todo: add your logic here and delete this line + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&refreshTokenReq{ + Token: in.GetToken(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } - return &permission.CreateOneTimeTokenResp{}, nil + // 驗證Token + claims, err := parseClaims(in.GetToken(), l.svcCtx.Config.Token.Secret, false) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "parseClaims"), + ).Error(err.Error()) + return nil, err + } + + token, err := l.svcCtx.TokenRedisRepo.GetAccessTokenByID(l.ctx, claims.ID()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.GetByAccess"), + logx.Field("claims", claims), + ).Error(err.Error()) + return nil, err + } + + oneTimeToken := generateRefreshToken(uuid.Must(uuid.NewRandom()).String()) + key := domain.TicketKeyPrefix + oneTimeToken + if err = l.svcCtx.TokenRedisRepo.CreateOneTimeToken(l.ctx, key, entity.Ticket{ + Data: claims, + Token: token, + }, time.Minute); err != nil { + return &permission.CreateOneTimeTokenResp{}, err + } + + return &permission.CreateOneTimeTokenResp{ + OneTimeToken: oneTimeToken, + }, nil } diff --git a/internal/logic/tokenservice/new_token_logic.go b/internal/logic/tokenservice/new_token_logic.go index e7e750c..259025d 100644 --- a/internal/logic/tokenservice/new_token_logic.go +++ b/internal/logic/tokenservice/new_token_logic.go @@ -1,7 +1,13 @@ package tokenservicelogic import ( + "app-cloudep-permission-server/internal/config" + "app-cloudep-permission-server/internal/domain" + "app-cloudep-permission-server/internal/entity" + ers "code.30cm.net/digimon/library-go/errors" "context" + "github.com/google/uuid" + "time" "app-cloudep-permission-server/gen_result/pb/permission" "app-cloudep-permission-server/internal/svc" @@ -23,9 +29,110 @@ func NewNewTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *NewToken } } +// https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 +type authorizationReq struct { + GrantType domain.GrantType `json:"grant_type" validate:"required,oneof=password client_credentials refresh_token"` + DeviceID string `json:"device_id"` + Scope string `json:"scope" validate:"required"` + Data map[string]string `json:"data"` + Expires int `json:"expires"` + IsRefreshToken bool `json:"is_refresh_token"` +} + // NewToken 建立一個新的 Token,例如:AccessToken func (l *NewTokenLogic) NewToken(in *permission.AuthorizationReq) (*permission.TokenResp, error) { - // todo: add your logic here and delete this line + data := authorizationReq{ + GrantType: domain.GrantType(in.GetGrantType()), + Scope: in.GetScope(), + DeviceID: in.GetDeviceId(), + Data: in.GetData(), + Expires: int(in.GetExpires()), + IsRefreshToken: in.GetIsRefreshToken(), + } + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&data); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + token, err := newToken(data, l.svcCtx.Config) + if err != nil { + return nil, err + } - return &permission.TokenResp{}, nil + err = l.svcCtx.TokenRedisRepo.Create(l.ctx, *token) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.Create"), + logx.Field("token", token), + ).Error(err.Error()) + return nil, err + } + + return &permission.TokenResp{ + AccessToken: token.AccessToken, + TokenType: domain.TokenTypeBearer, + ExpiresIn: int32(token.ExpiresIn), + RefreshToken: token.RefreshToken, + }, nil +} + +func newToken(authReq authorizationReq, cfg config.Config) (*entity.Token, error) { + // 準備建立 Token 所需 + now := time.Now().UTC() + expires := authReq.Expires + refreshExpires := authReq.Expires + if expires <= 0 { + // 將時間加上 300 秒 + sec := time.Duration(cfg.Token.Expired.Seconds()) * time.Second + newTime := now.Add(sec) + // 獲取 Unix 時間戳 + timestamp := newTime.Unix() + expires = int(timestamp) + refreshExpires = expires + } + + // 如果這是一個 Refresh Token 過期時間要比普通的Token 長 + if authReq.IsRefreshToken { + // 將時間加上 300 秒 + sec := time.Duration(cfg.Token.RefreshExpires.Seconds()) * time.Second + newTime := now.Add(sec) + // 獲取 Unix 時間戳 + timestamp := newTime.Unix() + refreshExpires = int(timestamp) + } + + token := entity.Token{ + ID: uuid.Must(uuid.NewRandom()).String(), + DeviceID: authReq.DeviceID, + ExpiresIn: expires, + RefreshExpiresIn: refreshExpires, + AccessCreateAt: now, + RefreshCreateAt: now, + } + + claims := claims(authReq.Data) + claims.SetRole(domain.DefaultRole) + claims.SetID(token.ID) + claims.SetScope(authReq.Scope) + + token.UID = claims.UID() + + if authReq.DeviceID != "" { + claims.SetDeviceID(authReq.DeviceID) + } + + var err error + token.AccessToken, err = generateAccessTokenFunc(token, claims, cfg.Token.Secret) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "generateAccessTokenFunc"), + logx.Field("claims", claims), + ).Error(err.Error()) + return nil, err + } + + if authReq.IsRefreshToken { + token.RefreshToken = generateRefreshTokenFunc(token.AccessToken) + } + + return &token, nil } diff --git a/internal/logic/tokenservice/refresh_token_logic.go b/internal/logic/tokenservice/refresh_token_logic.go index dd64bcb..52f6898 100644 --- a/internal/logic/tokenservice/refresh_token_logic.go +++ b/internal/logic/tokenservice/refresh_token_logic.go @@ -1,6 +1,8 @@ package tokenservicelogic import ( + "app-cloudep-permission-server/internal/domain" + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -23,9 +25,83 @@ func NewRefreshTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Refr } } +type refreshReq struct { + RefreshToken string `json:"grant_type" validate:"required"` + DeviceID string `json:"device_id" validate:"required"` + Scope string `json:"scope" validate:"required"` +} + // RefreshToken 更新目前的token 以及裡面包含的一次性 Token func (l *RefreshTokenLogic) RefreshToken(in *permission.RefreshTokenReq) (*permission.RefreshTokenResp, error) { - // todo: add your logic here and delete this line + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&refreshReq{ + RefreshToken: in.GetToken(), + Scope: in.GetScope(), + DeviceID: in.GetDeviceId(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } - return &permission.RefreshTokenResp{}, nil + // step 1 拿看看有沒有這個 refresh token + token, err := l.svcCtx.TokenRedisRepo.GetAccessTokenByByOneTimeToken(l.ctx, in.Token) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.GetByRefresh"), + logx.Field("req", in), + ).Error(err.Error()) + return nil, err + } + + // 取得 Data + c, err := parseClaims(token.AccessToken, l.svcCtx.Config.Token.Secret, false) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "parseClaims"), + logx.Field("token", token), + ).Error(err.Error()) + return nil, err + } + + // step 2 建立新 token + nt, err := newToken(authorizationReq{ + GrantType: domain.ClientCredentials, + Scope: in.GetScope(), + DeviceID: in.GetDeviceId(), + Data: c, + Expires: int(in.GetExpires()), + IsRefreshToken: true, + }, l.svcCtx.Config) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "newToken"), + logx.Field("req", in), + ).Error(err.Error()) + return nil, err + } + + // 刪除掉舊的 token + err = l.svcCtx.TokenRedisRepo.Delete(l.ctx, token) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.Delete"), + logx.Field("req", token), + ).Error(err.Error()) + return nil, err + } + + err = l.svcCtx.TokenRedisRepo.Create(l.ctx, *nt) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.Create"), + logx.Field("token", token), + ).Error(err.Error()) + return nil, err + } + + return &permission.RefreshTokenResp{ + Token: nt.AccessToken, + OneTimeToken: nt.RefreshToken, + ExpiresIn: int64(nt.ExpiresIn), + TokenType: domain.TokenTypeBearer, + }, nil } diff --git a/internal/logic/tokenservice/utils_claims.go b/internal/logic/tokenservice/utils_claims.go new file mode 100644 index 0000000..2d59b66 --- /dev/null +++ b/internal/logic/tokenservice/utils_claims.go @@ -0,0 +1,55 @@ +package tokenservicelogic + +type claims map[string]string + +func (c claims) SetID(id string) { + c["id"] = id +} + +func (c claims) SetRole(role string) { + c["role"] = role +} + +func (c claims) SetDeviceID(deviceID string) { + c["device_id"] = deviceID +} + +func (c claims) SetScope(scope string) { + c["scope"] = scope +} + +func (c claims) Role() string { + role, ok := c["role"] + if !ok { + return "" + } + + return role +} + +func (c claims) ID() string { + id, ok := c["id"] + if !ok { + return "" + } + + return id +} + +func (c claims) DeviceID() string { + deviceID, ok := c["device_id"] + if !ok { + return "" + } + + return deviceID +} + +func (c claims) UID() string { + uid, ok := c["uid"] + if !ok { + return "" + } + + return uid +} diff --git a/internal/logic/tokenservice/utils_jwt.go b/internal/logic/tokenservice/utils_jwt.go new file mode 100644 index 0000000..b7a6b2c --- /dev/null +++ b/internal/logic/tokenservice/utils_jwt.go @@ -0,0 +1,104 @@ +package tokenservicelogic + +import ( + "app-cloudep-permission-server/internal/domain" + "app-cloudep-permission-server/internal/entity" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "github.com/golang-jwt/jwt/v4" + "time" +) + +var generateAccessTokenFunc = generateAccessToken +var generateRefreshTokenFunc = generateRefreshToken + +func generateAccessToken(token entity.Token, data any, sign string) (string, error) { + claim := entity.Claims{ + Data: data, + RegisteredClaims: jwt.RegisteredClaims{ + ID: token.ID, + ExpiresAt: jwt.NewNumericDate(time.Unix(int64(token.ExpiresIn), 0)), + Issuer: "permission", + }, + } + + accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claim). + SignedString([]byte(sign)) + if err != nil { + return "", domain.TokenClaimError(err.Error()) + } + + return accessToken, nil +} + +func generateRefreshToken(accessToken string) string { + buf := bytes.NewBufferString(accessToken) + h := sha256.New() + _, _ = h.Write(buf.Bytes()) + + return hex.EncodeToString(h.Sum(nil)) +} + +func parseToken(accessToken string, secret string, validate bool) (jwt.MapClaims, error) { + // 跳過驗證的解析 + var token *jwt.Token + var err error + + if validate { + token, err = jwt.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, domain.TokenUnexpectedSigningErr(fmt.Sprintf("token unexpected signing method: %v", token.Header["alg"])) + } + return []byte(secret), nil + }) + if err != nil { + return jwt.MapClaims{}, err + } + } else { + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, err = parser.Parse(accessToken, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return jwt.MapClaims{}, err + } + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok && token.Valid { + return jwt.MapClaims{}, domain.TokenTokenValidateErr("token valid error") + } + + return claims, nil +} + +func parseClaims(accessToken string, secret string, validate bool) (claims, error) { + claimMap, err := parseToken(accessToken, secret, validate) + if err != nil { + return claims{}, err + } + + claimsData, ok := claimMap["data"].(map[string]any) + if ok { + return convertMap(claimsData), nil + } + + return claims{}, domain.TokenClaimError("get data from claim map error") +} + +func convertMap(input map[string]interface{}) map[string]string { + output := make(map[string]string) + for key, value := range input { + switch v := value.(type) { + case string: + output[key] = v + case fmt.Stringer: + output[key] = v.String() + default: + output[key] = fmt.Sprintf("%v", value) + } + } + return output +} diff --git a/internal/logic/tokenservice/validation_token_logic.go b/internal/logic/tokenservice/validation_token_logic.go index 0fe447a..25ce0d6 100644 --- a/internal/logic/tokenservice/validation_token_logic.go +++ b/internal/logic/tokenservice/validation_token_logic.go @@ -1,6 +1,7 @@ package tokenservicelogic import ( + ers "code.30cm.net/digimon/library-go/errors" "context" "app-cloudep-permission-server/gen_result/pb/permission" @@ -23,9 +24,46 @@ func NewValidationTokenLogic(ctx context.Context, svcCtx *svc.ServiceContext) *V } } +type refreshTokenReq struct { + Token string `json:"token" validate:"required"` +} + // ValidationToken 驗證這個 Token 有沒有效 func (l *ValidationTokenLogic) ValidationToken(in *permission.ValidationTokenReq) (*permission.ValidationTokenResp, error) { - // todo: add your logic here and delete this line + // 驗證所需 + if err := l.svcCtx.Validate.ValidateAll(&refreshTokenReq{ + Token: in.GetToken(), + }); err != nil { + return nil, ers.InvalidFormat(err.Error()) + } + claims, err := parseClaims(in.GetToken(), l.svcCtx.Config.Token.Secret, true) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "parseClaims"), + ).Info(err.Error()) + return nil, err + } + token, err := l.svcCtx.TokenRedisRepo.GetAccessTokenByID(l.ctx, claims.ID()) + if err != nil { + logx.WithCallerSkip(1).WithFields( + logx.Field("func", "TokenRedisRepo.GetByAccess"), + logx.Field("claims", claims), + ).Error(err.Error()) + return nil, err + } - return &permission.ValidationTokenResp{}, nil + return &permission.ValidationTokenResp{ + Token: &permission.Token{ + Id: token.ID, + Uid: token.UID, + DeviceId: token.DeviceID, + AccessCreateAt: token.AccessCreateAt.Unix(), + AccessToken: token.AccessToken, + ExpiresIn: int32(token.ExpiresIn), + RefreshToken: token.RefreshToken, + RefreshExpiresIn: int32(token.RefreshExpiresIn), + RefreshCreateAt: token.RefreshCreateAt.Unix(), + }, + Data: claims, + }, nil } diff --git a/internal/repository/token.go b/internal/repository/token.go new file mode 100644 index 0000000..7925006 --- /dev/null +++ b/internal/repository/token.go @@ -0,0 +1,302 @@ +package repository + +import ( + "app-cloudep-permission-server/internal/domain" + "app-cloudep-permission-server/internal/domain/repository" + "app-cloudep-permission-server/internal/entity" + "context" + "encoding/json" + "errors" + "fmt" + "time" + + ers "code.30cm.net/digimon/library-go/errors" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type TokenRepositoryParam struct { + Store *redis.Redis `name:"redis"` +} + +type tokenRepository struct { + store *redis.Redis +} + +func NewTokenRepository(param TokenRepositoryParam) repository.TokenRepository { + return &tokenRepository{ + store: param.Store, + } +} + +func (t *tokenRepository) Create(ctx context.Context, token entity.Token) error { + body, err := json.Marshal(token) + if err != nil { + return ers.ArkInternal("json.Marshal token error", err.Error()) + } + if err := t.store.Pipelined(func(tx redis.Pipeliner) error { + refreshTTL := time.Duration(token.RedisRefreshExpiredSec()) * time.Second + + if err := t.setToken(ctx, tx, token, body, refreshTTL); err != nil { + return err + } + + if err := t.setRefreshToken(ctx, tx, token, refreshTTL); err != nil { + return err + } + + return t.setRelation(ctx, tx, token.UID, token.DeviceID, token.ID, refreshTTL) + }); err != nil { + return repository.RedisPipLineError(err.Error()) + } + + return nil +} + +func (t *tokenRepository) Delete(ctx context.Context, token entity.Token) error { + keys := []string{ + domain.GetAccessTokenRedisKey(token.ID), + domain.RefreshTokenRedisKey.With(token.RefreshToken).ToString(), + } + + if err := t.deleteKeys(ctx, keys...); err != nil { + return repository.RedisPipLineError(err.Error()) + } + + _, _ = t.store.Srem(domain.DeviceTokenRedisKey.With(token.DeviceID).ToString(), token.ID) + _, _ = t.store.Srem(domain.UIDTokenRedisKey.With(token.UID).ToString(), token.ID) + + return nil +} + +func (t *tokenRepository) GetAccessTokenByID(ctx context.Context, id string) (entity.Token, error) { + token, err := t.get(ctx, domain.GetAccessTokenRedisKey(id)) + if err != nil { + return entity.Token{}, err + } + + return token, nil +} + +func (t *tokenRepository) DeleteAccessTokensByUID(ctx context.Context, uid string) error { + tokens, err := t.GetAccessTokensByUID(ctx, uid) + if err != nil { + return err + } + + for _, token := range tokens { + if err := t.Delete(ctx, token); err != nil { + return err + } + } + + return nil +} + +func (t *tokenRepository) DeleteAccessTokenByID(ctx context.Context, ids []string) error { + for _, tokenID := range ids { + token, err := t.GetAccessTokenByID(ctx, tokenID) + if err != nil { + continue + } + + keys := []string{ + domain.GetAccessTokenRedisKey(token.ID), + domain.RefreshTokenRedisKey.With(token.RefreshToken).ToString(), + } + + if err := t.deleteKeys(ctx, keys...); err != nil { + continue + } + + _, _ = t.store.Srem(domain.DeviceTokenRedisKey.With(token.DeviceID).ToString(), token.ID) + _, _ = t.store.Srem(domain.UIDTokenRedisKey.With(token.UID).ToString(), token.ID) + } + + return nil +} + +func (t *tokenRepository) GetAccessTokensByUID(ctx context.Context, uid string) ([]entity.Token, error) { + return t.getTokensBySet(ctx, domain.GetUIDTokenRedisKey(uid)) +} + +func (t *tokenRepository) GetAccessTokensByDeviceID(ctx context.Context, deviceID string) ([]entity.Token, error) { + return t.getTokensBySet(ctx, domain.DeviceTokenRedisKey.With(deviceID).ToString()) +} + +func (t *tokenRepository) DeleteAccessTokensByDeviceID(ctx context.Context, deviceID string) error { + + tokens, err := t.GetAccessTokensByDeviceID(ctx, deviceID) + if err != nil { + return repository.RedisDelError(fmt.Sprintf("GetAccessTokensByDeviceID error: %v", err)) + } + + var keys []string + for _, token := range tokens { + keys = append(keys, domain.GetAccessTokenRedisKey(token.ID)) + keys = append(keys, domain.RefreshTokenRedisKey.With(token.RefreshToken).ToString()) + + } + + err = t.store.Pipelined(func(tx redis.Pipeliner) error { + for _, token := range tokens { + _, _ = t.store.Srem(domain.UIDTokenRedisKey.With(token.UID).ToString(), token.ID) + } + return nil + }) + if err != nil { + return err + } + + if err := t.deleteKeys(ctx, keys...); err != nil { + return err + } + + _, err = t.store.Del(domain.DeviceTokenRedisKey.With(deviceID).ToString()) + return err +} + +func (t *tokenRepository) GetAccessTokenCountByDeviceID(deviceID string) (int, error) { + return t.getCountBySet(domain.DeviceTokenRedisKey.With(deviceID).ToString()) +} + +func (t *tokenRepository) GetAccessTokenCountByUID(uid string) (int, error) { + return t.getCountBySet(domain.UIDTokenRedisKey.With(uid).ToString()) +} + +func (t *tokenRepository) GetAccessTokenByByOneTimeToken(ctx context.Context, oneTimeToken string) (entity.Token, error) { + id, err := t.store.Get(domain.RefreshTokenRedisKey.With(oneTimeToken).ToString()) + if err != nil { + return entity.Token{}, repository.RedisError(fmt.Sprintf("GetAccessTokenByByOneTimeToken store.Get error: %s", err.Error())) + } + + if id == "" { + return entity.Token{}, ers.ResourceNotFound("token key not found in redis", domain.RefreshTokenRedisKey.With(oneTimeToken).ToString()) + } + + return t.GetAccessTokenByID(ctx, id) +} + +func (t *tokenRepository) DeleteOneTimeToken(ctx context.Context, ids []string, tokens []entity.Token) error { + var keys []string + + for _, id := range ids { + keys = append(keys, domain.RefreshTokenRedisKey.With(id).ToString()) + } + + for _, token := range tokens { + keys = append(keys, domain.RefreshTokenRedisKey.With(token.RefreshToken).ToString()) + } + + return t.deleteKeys(ctx, keys...) +} + +func (t *tokenRepository) CreateOneTimeToken(ctx context.Context, key string, ticket entity.Ticket, expires time.Duration) error { + body, err := json.Marshal(ticket) + if err != nil { + return ers.InvalidFormat("CreateOneTimeToken json.Marshal error", err.Error()) + } + + _, err = t.store.SetnxEx(domain.RefreshTokenRedisKey.With(key).ToString(), string(body), int(expires.Seconds())) + if err != nil { + return repository.RedisError(fmt.Sprintf("CreateOneTimeToken store.SetnxEx error: %s", err.Error())) + } + + return nil +} + +// -------------------- Private area -------------------- + +func (t *tokenRepository) get(ctx context.Context, key string) (entity.Token, error) { + body, err := t.store.GetCtx(ctx, key) + if err != nil { + return entity.Token{}, repository.RedisError(fmt.Sprintf("token %s not found in redis: %s", key, err.Error())) + } + + if body == "" { + return entity.Token{}, ers.ResourceNotFound("this token not found") + } + + var token entity.Token + if err := json.Unmarshal([]byte(body), &token); err != nil { + return entity.Token{}, ers.ArkInternal("json.Unmarshal token error", err.Error()) + } + + return token, nil +} + +func (t *tokenRepository) setToken(ctx context.Context, tx redis.Pipeliner, token entity.Token, body []byte, ttl time.Duration) error { + return tx.Set(ctx, domain.GetAccessTokenRedisKey(token.ID), body, ttl).Err() +} + +func (t *tokenRepository) setRefreshToken(ctx context.Context, tx redis.Pipeliner, token entity.Token, ttl time.Duration) error { + if token.RefreshToken != "" { + return tx.Set(ctx, domain.RefreshTokenRedisKey.With(token.RefreshToken).ToString(), token.ID, ttl).Err() + } + return nil +} + +func (t *tokenRepository) setRelation(ctx context.Context, tx redis.Pipeliner, uid, deviceID, tokenID string, ttl time.Duration) error { + if err := tx.SAdd(ctx, domain.UIDTokenRedisKey.With(uid).ToString(), tokenID).Err(); err != nil { + return err + } + + if err := tx.SAdd(ctx, domain.DeviceTokenRedisKey.With(deviceID).ToString(), tokenID).Err(); err != nil { + return err + } + + return nil +} + +func (t *tokenRepository) deleteKeys(ctx context.Context, keys ...string) error { + return t.store.Pipelined(func(tx redis.Pipeliner) error { + for _, key := range keys { + if err := tx.Del(ctx, key).Err(); err != nil { + return repository.RedisDelError(fmt.Sprintf("store.Del key error: %v", err)) + } + } + return nil + }) +} + +func (t *tokenRepository) getTokensBySet(ctx context.Context, setKey string) ([]entity.Token, error) { + ids, err := t.store.Smembers(setKey) + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, repository.RedisError(fmt.Sprintf("getTokensBySet store.Get %s error: %v", setKey, err.Error())) + } + + var tokens []entity.Token + var deleteTokens []string + now := time.Now().Unix() + for _, id := range ids { + token, err := t.get(ctx, domain.GetAccessTokenRedisKey(id)) + if err != nil { + deleteTokens = append(deleteTokens, id) + continue + } + + if int64(token.ExpiresIn) < now { + deleteTokens = append(deleteTokens, id) + continue + } + + tokens = append(tokens, token) + } + + if len(deleteTokens) > 0 { + _ = t.DeleteAccessTokenByID(ctx, deleteTokens) + } + + return tokens, nil +} + +func (t *tokenRepository) getCountBySet(setKey string) (int, error) { + count, err := t.store.Scard(setKey) + if err != nil { + return 0, err + } + return int(count), nil +} diff --git a/internal/server/tokenservice/token_service_server.go b/internal/server/tokenservice/token_service_server.go index 0ad10aa..2620970 100644 --- a/internal/server/tokenservice/token_service_server.go +++ b/internal/server/tokenservice/token_service_server.go @@ -7,7 +7,7 @@ import ( "context" "app-cloudep-permission-server/gen_result/pb/permission" - "app-cloudep-permission-server/internal/logic/tokenservice" + tokenservicelogic "app-cloudep-permission-server/internal/logic/tokenservice" "app-cloudep-permission-server/internal/svc" ) diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index c12077a..17c9abe 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -1,13 +1,42 @@ package svc -import "app-cloudep-permission-server/internal/config" +import ( + "app-cloudep-permission-server/internal/config" + "app-cloudep-permission-server/internal/domain/repository" + repo "app-cloudep-permission-server/internal/repository" + + ers "code.30cm.net/digimon/library-go/errors" + "code.30cm.net/digimon/library-go/errors/code" + vi "code.30cm.net/digimon/library-go/validator" + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) type ServiceContext struct { Config config.Config + Conn sqlx.SqlConn + + Validate vi.Validate + Redis redis.Redis + TokenRedisRepo repository.TokenRepository } func NewServiceContext(c config.Config) *ServiceContext { + ers.Scope = code.CloudEPPermission + sqlConn := sqlx.NewMysql(c.DB.DsnString) + + newRedis, err := redis.NewRedis(c.RedisCluster, redis.Cluster()) + if err != nil { + panic(err) + } + return &ServiceContext{ - Config: c, + Conn: sqlConn, + Config: c, + Validate: vi.MustValidator(), + Redis: *newRedis, + TokenRedisRepo: repo.NewTokenRepository(repo.TokenRepositoryParam{ + Store: newRedis, + }), } }